fix(admin): harden dashboard billing operations (#6914)

* feat(admin): make dashboard billing operations durable

* fix(admin): close dashboard recovery gaps

* fix(admin): preserve member operation lock order

* fix(admin): harden durable operation boundaries

* fix(admin): report member operation failures accurately

* test(invitations): cover locked seat admission

* feat(enterprise): gate owner activation on acceptance

* fix(enterprise): keep owner activation recoverable
This commit is contained in:
Vikhyath Mondreti
2026-08-20 22:31:11 -07:00
committed by GitHub
parent dbbe99e473
commit ab66ce9149
84 changed files with 11058 additions and 873 deletions
@@ -0,0 +1,62 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { acceptEnterpriseOwnerClaimContract } from '@/lib/api/contracts/enterprise-owner-claims'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { acceptEnterpriseOwnerClaim } from '@/lib/billing/enterprise-owner-claim'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('EnterpriseOwnerClaimAcceptAPI')
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
if (!session.user.emailVerified) {
return NextResponse.json(
{
error: 'email-unverified',
message: 'Verify the invited email before accepting Enterprise ownership.',
},
{ status: 403 }
)
}
const parsed = await parseRequest(acceptEnterpriseOwnerClaimContract, request, context)
if (!parsed.success) return parsed.response
const result = await acceptEnterpriseOwnerClaim({
claimId: parsed.data.params.id,
token: parsed.data.body.token,
userId: session.user.id,
userEmail: session.user.email,
userName: session.user.name,
disclosedWorkspaceIds: parsed.data.body.disclosedWorkspaceIds,
disclosedCreatesDefaultWorkspace: parsed.data.body.disclosedCreatesDefaultWorkspace,
})
if (!result.success) {
const statusByKind: Record<typeof result.kind, number> = {
'not-found': 404,
'invalid-token': 400,
expired: 400,
revoked: 400,
'email-mismatch': 403,
'already-in-organization': 409,
'disclosure-outdated': 409,
'workspace-limit': 400,
'workspace-invitation-limit': 400,
'insufficient-seats': 400,
'server-error': 500,
}
logger.warn('Enterprise owner claim acceptance rejected', {
claimId: parsed.data.params.id,
reason: result.kind,
})
return NextResponse.json(
{ error: result.kind, ...(result.message ? { message: result.message } : {}) },
{ status: statusByKind[result.kind] }
)
}
return NextResponse.json(result)
}
)
@@ -0,0 +1,68 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getEnterpriseOwnerClaimContract } from '@/lib/api/contracts/enterprise-owner-claims'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import {
EnterpriseOwnerClaimEmailMismatchError,
EnterpriseOwnerClaimWorkspaceLimitError,
getEnterpriseOwnerClaimDetails,
} from '@/lib/billing/enterprise-owner-claim'
import { EnterpriseProvisioningError } from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('EnterpriseOwnerClaimAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const session = await getSession()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
if (!session.user.emailVerified) {
return NextResponse.json(
{
error: 'email-unverified',
message: 'Verify the invited email before reviewing Enterprise ownership.',
},
{ status: 403 }
)
}
const parsed = await parseRequest(getEnterpriseOwnerClaimContract, request, context)
if (!parsed.success) return parsed.response
try {
const details = await getEnterpriseOwnerClaimDetails({
claimId: parsed.data.params.id,
token: parsed.data.query.token,
userId: session.user.id,
userEmail: session.user.email,
})
if (!details) return NextResponse.json({ error: 'not-found' }, { status: 404 })
return NextResponse.json(details)
} catch (error) {
if (error instanceof EnterpriseOwnerClaimEmailMismatchError) {
return NextResponse.json(
{ error: 'email-mismatch', message: error.message },
{ status: 403 }
)
}
if (error instanceof EnterpriseOwnerClaimWorkspaceLimitError) {
return NextResponse.json(
{ error: 'workspace-limit', message: error.message },
{ status: 400 }
)
}
if (error instanceof EnterpriseProvisioningError) {
return NextResponse.json(
{ error: 'setup-blocked', message: error.message },
{ status: 400 }
)
}
logger.error('Failed to load Enterprise owner claim', {
claimId: parsed.data.params.id,
error,
})
return NextResponse.json({ error: 'server-error' }, { status: 500 })
}
}
)
@@ -0,0 +1,35 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { adminDashboardRetryEnterpriseOwnerClaimContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { retryEnterpriseOwnerClaim } from '@/lib/billing/enterprise-owner-claim'
import { EnterpriseProvisioningError } from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminEnterpriseOwnerClaimRetryAPI')
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRetryEnterpriseOwnerClaimContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
return singleResponse(await retryEnterpriseOwnerClaim(parsed.data.params.id))
} catch (error) {
if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
logger.error('Failed to retry Enterprise owner invitation', { error })
return internalErrorResponse(getErrorMessage(error, 'Failed to retry owner invitation'))
}
})
)
@@ -0,0 +1,43 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { adminDashboardRevokeEnterpriseOwnerClaimContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { revokeEnterpriseOwnerClaim } from '@/lib/billing/enterprise-owner-claim'
import { EnterpriseProvisioningError } from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminEnterpriseOwnerClaimRevokeAPI')
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRevokeEnterpriseOwnerClaimContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
const actor = await getAdminAuditActor(request)
return singleResponse(
await revokeEnterpriseOwnerClaim(parsed.data.params.id, {
id: actor.id,
name: actor.name,
email: actor.email,
})
)
} catch (error) {
if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
logger.error('Failed to revoke Enterprise owner invitation', { error })
return internalErrorResponse(getErrorMessage(error, 'Failed to revoke owner invitation'))
}
})
)
@@ -0,0 +1,52 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { adminDashboardReviewEnterpriseOwnerClaimContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { reviewEnterpriseOwnerClaim } from '@/lib/billing/enterprise-owner-claim'
import { EnterpriseProvisioningError } from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminEnterpriseOwnerClaimReviewAPI')
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminDashboardReviewEnterpriseOwnerClaimContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
const { usageLimitDollars, ...body } = parsed.data.body
return singleResponse(
await reviewEnterpriseOwnerClaim({
...body,
usageLimitCredits:
usageLimitDollars === undefined ? undefined : dollarsToCredits(usageLimitDollars),
requestedByEmail: 'admin-review',
requestedByUserId: null,
requestedByName: 'Admin Panel',
})
)
} catch (error) {
if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
logger.error('Failed to review Enterprise owner claim', { error })
return internalErrorResponse(
getErrorMessage(error, 'Failed to review the Enterprise owner invitation')
)
}
})
)
@@ -0,0 +1,84 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
adminDashboardCreateEnterpriseOwnerClaimContract,
adminDashboardListEnterpriseOwnerClaimsContract,
} from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
createEnterpriseOwnerClaim,
getOpenEnterpriseOwnerClaimsPage,
} from '@/lib/billing/enterprise-owner-claim'
import { EnterpriseProvisioningError } from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
listResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminEnterpriseOwnerClaimsAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminDashboardListEnterpriseOwnerClaimsContract,
request,
{},
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
const { limit, offset } = parsed.data.query
const result = await getOpenEnterpriseOwnerClaimsPage({ limit, offset })
return listResponse(result.data, {
total: result.total,
limit,
offset,
hasMore: offset + result.data.length < result.total,
})
} catch (error) {
logger.error('Failed to list Enterprise owner claims', { error })
return internalErrorResponse(getErrorMessage(error, 'Failed to list owner invitations'))
}
})
)
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminDashboardCreateEnterpriseOwnerClaimContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
const actor = await getAdminAuditActor(request)
const { usageLimitDollars, ...body } = parsed.data.body
return singleResponse(
await createEnterpriseOwnerClaim({
...body,
usageLimitCredits:
usageLimitDollars === undefined ? undefined : dollarsToCredits(usageLimitDollars),
requestedByEmail: actor.email ?? 'admin-api',
requestedByUserId: actor.id,
requestedByName: actor.name,
})
)
} catch (error) {
if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
logger.error('Failed to create Enterprise owner claim', { error })
return internalErrorResponse(getErrorMessage(error, 'Failed to invite the Enterprise owner'))
}
})
)
@@ -0,0 +1,50 @@
import { getErrorMessage } from '@sim/utils/errors'
import { toDashboardProvisioning } from '@/lib/admin/dashboard'
import { adminDashboardRetryEnterpriseFollowUpJobContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import {
EnterpriseProvisioningError,
retryEnterpriseFollowUpJob,
} from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
jobId: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRetryEnterpriseFollowUpJobContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
return singleResponse(
toDashboardProvisioning(
await retryEnterpriseFollowUpJob(
parsed.data.params.id,
parsed.data.params.jobId,
await getAdminAuditActor(request)
)
)
)
} catch (error) {
return badRequestResponse(
error instanceof EnterpriseProvisioningError
? error.message
: getErrorMessage(error, 'Failed to retry Enterprise follow-up job')
)
}
})
)
@@ -0,0 +1,50 @@
import { getErrorMessage } from '@sim/utils/errors'
import { toDashboardProvisioning } from '@/lib/admin/dashboard'
import { adminDashboardRetryEnterpriseInvitationContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import {
EnterpriseProvisioningError,
retryEnterpriseInvitation,
} from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
inviteId: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRetryEnterpriseInvitationContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
return singleResponse(
toDashboardProvisioning(
await retryEnterpriseInvitation(
parsed.data.params.id,
parsed.data.params.inviteId,
await getAdminAuditActor(request)
)
)
)
} catch (error) {
return badRequestResponse(
error instanceof EnterpriseProvisioningError
? error.message
: getErrorMessage(error, 'Failed to retry Enterprise invitation')
)
}
})
)
@@ -0,0 +1,49 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { adminDashboardEnterpriseReviewContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
EnterpriseProvisioningError,
reviewEnterpriseProvisioning,
} from '@/lib/billing/enterprise-provisioning'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminEnterpriseProvisioningReviewAPI')
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminDashboardEnterpriseReviewContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
const { usageLimitDollars, ...body } = parsed.data.body
return singleResponse(
await reviewEnterpriseProvisioning({
...body,
usageLimitCredits:
usageLimitDollars === undefined ? undefined : dollarsToCredits(usageLimitDollars),
})
)
} catch (error) {
if (error instanceof EnterpriseProvisioningError) return badRequestResponse(error.message)
logger.error('Failed to review Enterprise provisioning', { error })
return internalErrorResponse(getErrorMessage(error, 'Failed to review Enterprise plan'))
}
})
)
@@ -44,6 +44,7 @@ export const POST = withRouteHandler(
usageLimitDollars === undefined ? undefined : dollarsToCredits(usageLimitDollars),
requestedByEmail: actor.email ?? 'admin-api',
requestedByUserId: actor.id,
requestedByName: actor.name,
})
)
)
@@ -0,0 +1,29 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getDashboardSubscriptionBillingActions } from '@/lib/admin/subscription-lifecycle'
import { adminDashboardBillingActionsContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminDashboardBillingActionsAPI')
export const GET = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardBillingActionsContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
try {
return singleResponse(await getDashboardSubscriptionBillingActions(parsed.data.params.id))
} catch (error) {
logger.warn('Could not load organization billing actions', { error })
return badRequestResponse(getErrorMessage(error, 'Could not load billing actions'))
}
})
)
@@ -0,0 +1,34 @@
import { getErrorMessage } from '@sim/utils/errors'
import { requestDashboardSubscriptionCancellation } from '@/lib/admin/subscription-lifecycle'
import { adminDashboardCancelSubscriptionContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardCancelSubscriptionContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
try {
return singleResponse(
await requestDashboardSubscriptionCancellation({
organizationId: parsed.data.params.id,
...parsed.data.body,
actor: await getAdminAuditActor(request),
})
)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Could not cancel subscription'))
}
})
)
@@ -0,0 +1,40 @@
import { getErrorMessage } from '@sim/utils/errors'
import { retryAdminInvitationOperationJob } from '@/lib/admin/invitation-operation'
import { adminDashboardRetryInvitationOperationJobContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
operationId: string
jobId: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRetryInvitationOperationJobContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
return singleResponse(
await retryAdminInvitationOperationJob(
parsed.data.params.id,
parsed.data.params.operationId,
parsed.data.params.jobId
)
)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to retry invitation operation job'))
}
})
)
@@ -0,0 +1,35 @@
import { getErrorMessage } from '@sim/utils/errors'
import { getAdminInvitationOperation } from '@/lib/admin/invitation-operation'
import { adminDashboardGetInvitationOperationContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
operationId: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardGetInvitationOperationContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
return singleResponse(
await getAdminInvitationOperation(parsed.data.params.id, parsed.data.params.operationId)
)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to load invitation operation'))
}
})
)
@@ -0,0 +1,38 @@
import { getErrorMessage } from '@sim/utils/errors'
import { createAdminInvitationOperation } from '@/lib/admin/invitation-operation'
import { adminDashboardInvitePeopleContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardInvitePeopleContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
try {
return singleResponse(
await createAdminInvitationOperation({
operationId: parsed.data.body.operationId,
organizationId: parsed.data.params.id,
emails: parsed.data.body.emails,
workspaceIds: parsed.data.body.workspaceIds,
role: parsed.data.body.role,
permission: parsed.data.body.permission,
actor: await getAdminAuditActor(request),
})
)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to invite people'))
}
})
)
@@ -0,0 +1,44 @@
import { getErrorMessage } from '@sim/utils/errors'
import { retryAdminMemberFollowUpJob } from '@/lib/admin/member-operation'
import { adminDashboardRetryMemberFollowUpJobContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
operationId: string
jobId: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRetryMemberFollowUpJobContract,
request,
context,
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
return singleResponse(
await retryAdminMemberFollowUpJob(
parsed.data.params.id,
parsed.data.params.operationId,
parsed.data.params.jobId,
await getAdminAuditActor(request)
)
)
} catch (error) {
return badRequestResponse(
getErrorMessage(error, 'Could not retry member-operation follow-up job')
)
}
})
)
@@ -0,0 +1,27 @@
import { getErrorMessage } from '@sim/utils/errors'
import { getAdminMemberOperation } from '@/lib/admin/member-operation'
import { adminDashboardMemberOperationContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
export const GET = withRouteHandler(
withAdminAuthParams<{ id: string; operationId: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardMemberOperationContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
try {
return singleResponse(
await getAdminMemberOperation(parsed.data.params.id, parsed.data.params.operationId)
)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to load member operation'))
}
})
)
@@ -18,7 +18,11 @@ export const GET = withRouteHandler(
if (!parsed.success) return parsed.response
try {
return singleResponse(
await getDashboardMemberTransferPreflight(parsed.data.params.id, parsed.data.query.userId)
await getDashboardMemberTransferPreflight(parsed.data.params.id, parsed.data.query.userId, {
search: parsed.data.query.search,
limit: parsed.data.query.limit,
offset: parsed.data.query.offset,
})
)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to prepare member transfer'))
@@ -1,5 +1,5 @@
import { getErrorMessage } from '@sim/utils/errors'
import { addDashboardOrganizationMember } from '@/lib/admin/dashboard'
import { startAdminMemberOperation } from '@/lib/admin/member-operation'
import { adminDashboardAddMemberContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -20,12 +20,14 @@ export const POST = withRouteHandler(
})
if (!parsed.success) return parsed.response
try {
const result = await addDashboardOrganizationMember(
const { operationId, ...body } = parsed.data.body
const result = await startAdminMemberOperation(
operationId,
parsed.data.params.id,
parsed.data.body,
body,
await getAdminAuditActor(request)
)
return singleResponse({ success: true as const, ...result })
return singleResponse(result)
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to add member'))
}
@@ -0,0 +1,40 @@
import { getErrorMessage } from '@sim/utils/errors'
import {
RefundOperationRejectedError,
refundDashboardSubscriptionPayment,
} from '@/lib/admin/subscription-lifecycle'
import { adminDashboardRefundContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardRefundContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
try {
return singleResponse(
await refundDashboardSubscriptionPayment({
organizationId: parsed.data.params.id,
...parsed.data.body,
actor: await getAdminAuditActor(request),
})
)
} catch (error) {
if (error instanceof RefundOperationRejectedError) {
return badRequestResponse(error.message, { refundOperation: 'not_created' })
}
return badRequestResponse(getErrorMessage(error, 'Could not issue refund'))
}
})
)
@@ -1,11 +1,18 @@
import { createLogger } from '@sim/logger'
import { getDashboardOrganization } from '@/lib/admin/dashboard'
import { adminDashboardGetOrganizationContract } from '@/lib/api/contracts/v1/admin/dashboard'
import { getErrorMessage } from '@sim/utils/errors'
import { getDashboardOrganization, renameDashboardOrganization } from '@/lib/admin/dashboard'
import {
adminDashboardGetOrganizationContract,
adminDashboardRenameOrganizationContract,
} from '@/lib/api/contracts/v1/admin/dashboard'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
@@ -28,3 +35,23 @@ export const GET = withRouteHandler(
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminDashboardRenameOrganizationContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
try {
await renameDashboardOrganization(
parsed.data.params.id,
parsed.data.body.name,
await getAdminAuditActor(request)
)
return singleResponse({ success: true as const })
} catch (error) {
return badRequestResponse(getErrorMessage(error, 'Failed to rename organization'))
}
})
)
@@ -0,0 +1,43 @@
import { getErrorMessage } from '@sim/utils/errors'
import { NextResponse } from 'next/server'
import { adminDashboardRetryWorkspaceMoveFollowUpContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { retryWorkspaceMoveFollowUpJob, WorkspaceMoveError } from '@/lib/workspaces/admin-move'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { badRequestResponse, internalErrorResponse } from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
operationId: string
jobId: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardRetryWorkspaceMoveFollowUpContract,
request,
context
)
if (!parsed.success) return parsed.response
try {
return NextResponse.json({
data: await retryWorkspaceMoveFollowUpJob({
workspaceId: parsed.data.params.id,
destinationOrganizationId: parsed.data.body.destinationOrganizationId,
expectedOwnerId: parsed.data.body.expectedOwnerId,
operationId: parsed.data.params.operationId,
jobEventId: parsed.data.params.jobId,
actor: await getAdminAuditActor(request),
}),
})
} catch (error) {
if (error instanceof WorkspaceMoveError) return badRequestResponse(error.message)
return internalErrorResponse(
getErrorMessage(error, 'Could not retry the workspace-move follow-up job')
)
}
})
)
@@ -0,0 +1,39 @@
import { getErrorMessage } from '@sim/utils/errors'
import { NextResponse } from 'next/server'
import { adminDashboardWorkspaceMoveOperationContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getWorkspaceMoveOperation, WorkspaceMoveError } from '@/lib/workspaces/admin-move'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { badRequestResponse, internalErrorResponse } from '@/app/api/v1/admin/responses'
interface RouteParams {
id: string
operationId: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(
adminDashboardWorkspaceMoveOperationContract,
request,
context
)
if (!parsed.success) return parsed.response
try {
return NextResponse.json({
data: await getWorkspaceMoveOperation(
parsed.data.params.id,
parsed.data.query.destinationOrganizationId,
parsed.data.query.expectedOwnerId,
parsed.data.params.operationId
),
})
} catch (error) {
if (error instanceof WorkspaceMoveError) return badRequestResponse(error.message)
return internalErrorResponse(
getErrorMessage(error, 'Could not load the workspace-move operation')
)
}
})
)
@@ -4,7 +4,12 @@ import { NextResponse } from 'next/server'
import { adminDashboardWorkspaceMoveContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { moveWorkspaceToOrganization, WorkspaceMoveError } from '@/lib/workspaces/admin-move'
import {
moveWorkspaceToOrganization,
toWorkspaceMoveOperationView,
WorkspaceMoveError,
} from '@/lib/workspaces/admin-move'
import { getAdminAuditActor } from '@/app/api/v1/admin/dashboard/actor'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -24,12 +29,18 @@ export const POST = withRouteHandler(
if (!parsed.success) return parsed.response
try {
const data = await moveWorkspaceToOrganization({
const actor = await getAdminAuditActor(request)
const summary = await moveWorkspaceToOrganization({
workspaceId: parsed.data.params.id,
destinationOrganizationId: parsed.data.body.destinationOrganizationId,
expectedOwnerId: parsed.data.body.expectedOwnerId,
adminEmail: request.headers.get('x-admin-email') ?? 'admin-api@sim.ai',
auditActor: actor,
auditOperationId: parsed.data.body.operationId,
operationCorrelationId: parsed.data.body.operationId,
durableOperationId: parsed.data.body.operationId,
})
const data = await toWorkspaceMoveOperationView(summary, parsed.data.body.operationId)
return NextResponse.json({ data })
} catch (error) {
if (error instanceof WorkspaceMoveError) {
@@ -16,11 +16,12 @@ export const GET = withRouteHandler(
if (!parsed.success) return parsed.response
try {
const data = await searchWorkspaceMoveCandidates(
const result = await searchWorkspaceMoveCandidates(
parsed.data.query.search,
parsed.data.query.limit
parsed.data.query.limit,
parsed.data.query.offset
)
return NextResponse.json({ data })
return NextResponse.json(result)
} catch (error) {
logger.error('Failed to search workspace move candidates', {
error: getErrorMessage(error),
@@ -2,13 +2,17 @@ import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { adminInvitationOperationOutboxHandlers } from '@/lib/admin/invitation-operation'
import { adminMemberOperationOutboxHandlers } from '@/lib/admin/member-operation'
import { verifyCronAuth } from '@/lib/auth/internal'
import { enterpriseOwnerClaimOutboxHandlers } from '@/lib/billing/enterprise-owner-claim'
import { enterpriseIssuanceOutboxHandlers } from '@/lib/billing/enterprise-provisioning'
import { membershipBillingOutboxHandlers } from '@/lib/billing/organizations/membership-reconciliation'
import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant'
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
@@ -21,10 +25,14 @@ export const dynamic = 'force-dynamic'
export const maxDuration = 120
const handlers = {
...adminInvitationOperationOutboxHandlers,
...adminMemberOperationOutboxHandlers,
...billingOutboxHandlers,
...membershipBillingOutboxHandlers,
...enterpriseIssuanceOutboxHandlers,
...enterpriseOwnerClaimOutboxHandlers,
...invitationMigrationOutboxHandlers,
...directGrantOutboxHandlers,
...knowledgeDocumentProcessingOutboxHandlers,
...workspaceFileStorageCleanupOutboxHandlers,
...workflowDeploymentOutboxHandlers,
@@ -5,6 +5,7 @@ import {
auditMock,
authMockFns,
createMockRequest,
dbChainMock,
permissionsMock,
permissionsMockFns,
posthogServerMock,
@@ -15,32 +16,47 @@ import {
setEnvFlags,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DbOrTx } from '@/lib/db/types'
import type { CreatePendingInvitationInput } from '@/lib/invitations/send'
const {
MockConflictingPendingInvitationError,
mockGetWorkspaceInvitePolicy,
mockValidateInvitationsAllowed,
mockValidateSeatAvailability,
mockAcquireOrganizationMutationLock,
mockAcquireOrganizationUserMutationLocks,
mockGetUserOrganization,
mockGetEffectiveWorkspacePermission,
mockCreatePendingInvitation,
mockSendInvitationEmail,
mockCancelPendingInvitation,
mockRevertPendingInvitationGrants,
mockFindPendingGrantWorkspaceIds,
mockFindPendingOrganizationInvitation,
mockGetInvitePlanCategoryForUser,
} = vi.hoisted(() => ({
MockConflictingPendingInvitationError: class extends Error {},
mockGetWorkspaceInvitePolicy: vi.fn(),
mockValidateInvitationsAllowed: vi.fn().mockResolvedValue(undefined),
mockValidateSeatAvailability: vi.fn(),
mockAcquireOrganizationMutationLock: vi.fn(),
mockAcquireOrganizationUserMutationLocks: vi.fn(),
mockGetUserOrganization: vi.fn(),
mockGetEffectiveWorkspacePermission: vi.fn(),
mockCreatePendingInvitation: vi.fn(),
mockSendInvitationEmail: vi.fn(),
mockCancelPendingInvitation: vi.fn(),
mockRevertPendingInvitationGrants: vi.fn(),
mockFindPendingGrantWorkspaceIds: vi.fn(),
mockFindPendingOrganizationInvitation: vi.fn(),
mockGetInvitePlanCategoryForUser: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/workspaces/permissions/utils', () => ({
...permissionsMock,
getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission,
}))
vi.mock('@/lib/workspaces/policy', () => ({
getWorkspaceInvitePolicy: mockGetWorkspaceInvitePolicy,
@@ -56,15 +72,19 @@ vi.mock('@/lib/billing/validation/seat-management', () => ({
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
acquireOrganizationMutationLock: mockAcquireOrganizationMutationLock,
acquireOrganizationUserMutationLocks: mockAcquireOrganizationUserMutationLocks,
getUserOrganization: mockGetUserOrganization,
}))
vi.mock('@/lib/invitations/send', () => ({
ConflictingPendingInvitationError: MockConflictingPendingInvitationError,
createPendingInvitation: mockCreatePendingInvitation,
sendInvitationEmail: mockSendInvitationEmail,
cancelPendingInvitation: mockCancelPendingInvitation,
revertPendingInvitationGrants: mockRevertPendingInvitationGrants,
findPendingGrantWorkspaceIds: mockFindPendingGrantWorkspaceIds,
findPendingOrganizationInvitation: mockFindPendingOrganizationInvitation,
}))
vi.mock('@/lib/invitations/core', () => ({
@@ -125,7 +145,10 @@ describe('POST /api/workspaces/invitations/batch', () => {
maxSeats: 5,
availableSeats: 4,
})
mockAcquireOrganizationMutationLock.mockResolvedValue(undefined)
mockAcquireOrganizationUserMutationLocks.mockResolvedValue(undefined)
mockGetUserOrganization.mockResolvedValue(null)
mockGetEffectiveWorkspacePermission.mockResolvedValue('admin')
mockCreatePendingInvitation.mockImplementation(
async (input: { grants: Array<{ workspaceId: string; permission: string }> }) => ({
invitationId: 'inv-1',
@@ -139,7 +162,10 @@ describe('POST /api/workspaces/invitations/batch', () => {
})
)
mockSendInvitationEmail.mockResolvedValue({ success: true })
mockCancelPendingInvitation.mockResolvedValue(true)
mockRevertPendingInvitationGrants.mockResolvedValue(true)
mockFindPendingGrantWorkspaceIds.mockResolvedValue(new Set())
mockFindPendingOrganizationInvitation.mockResolvedValue(null)
mockGetInvitePlanCategoryForUser.mockResolvedValue('free')
})
@@ -210,7 +236,7 @@ describe('POST /api/workspaces/invitations/batch', () => {
})
it('reports org-owned invites as failed when the organization has no available seats', async () => {
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'workspace-1',
name: 'Org Workspace',
ownerId: 'user-1',
@@ -232,6 +258,16 @@ describe('POST /api/workspaces/invitations/batch', () => {
maxSeats: 5,
availableSeats: 0,
})
mockCreatePendingInvitation.mockImplementationOnce(
async (input: CreatePendingInvitationInput) => {
await input.validateLockedContext?.({
tx: dbChainMock.db as unknown as DbOrTx,
organizationId: 'org-1',
workspaceIds: ['workspace-1'],
})
throw new Error('unreachable')
}
)
const request = createMockRequest('POST', {
workspaceIds: ['workspace-1'],
@@ -250,8 +286,9 @@ describe('POST /api/workspaces/invitations/batch', () => {
error: 'No available seats. Currently using 5 of 5 seats.',
},
])
expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1)
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1, {
executor: dbChainMock.db,
})
})
it('creates an external workspace invitation for users already in another organization', async () => {
+2 -166
View File
@@ -1,8 +1,7 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
import { type WorkspaceMode, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
@@ -10,19 +9,12 @@ import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { PlatformEvents } from '@/lib/core/telemetry'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
import { createWorkspace } from '@/lib/workspaces/create'
import { listWorkspacesForViewer } from '@/lib/workspaces/list'
import {
getWorkspaceCreationPolicy,
getWorkspaceInvitePolicy,
lockWorkspaceCreationContext,
resolveInviteFlags,
WORKSPACE_MODE,
WorkspaceCreationContextChangedError,
} from '@/lib/workspaces/policy'
@@ -225,162 +217,6 @@ async function createDefaultWorkspace(
})
}
interface CreateWorkspaceParams {
userId: string
/** Membership the creation policy observed; see WorkspaceCreationPolicy. */
observedOrganizationId: string | null
name: string
skipDefaultWorkflow?: boolean
explicitColor?: string
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
}
async function createWorkspace({
userId,
observedOrganizationId,
name,
skipDefaultWorkflow = false,
explicitColor,
organizationId,
workspaceMode,
billedAccountUserId,
}: CreateWorkspaceParams) {
const workspaceId = generateId()
const workflowId = generateId()
const now = new Date()
const color = explicitColor || getRandomWorkspaceColor()
let committedBilledAccountUserId = billedAccountUserId
try {
await db.transaction(async (tx) => {
/**
* Creation takes the same organization user membership fence as
* source access removal and transfer. If creation commits first, their
* post-lock workspace-set re-read sees this row and cleans it up. If the
* membership mutation commits first, this re-read rejects the stale
* creation policy before inserting anything.
*/
const lockedCreationContext = await lockWorkspaceCreationContext(tx, {
userId,
organizationId,
observedOrganizationId,
})
const currentBilledAccountUserId =
workspaceMode === WORKSPACE_MODE.ORGANIZATION
? lockedCreationContext.billedAccountUserId
: billedAccountUserId
committedBilledAccountUserId = currentBilledAccountUserId
await tx.insert(workspace).values({
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId: currentBilledAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
})
const permissionRows = [
{
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
},
]
if (
workspaceMode === WORKSPACE_MODE.ORGANIZATION &&
currentBilledAccountUserId &&
currentBilledAccountUserId !== userId
) {
permissionRows.push({
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId: currentBilledAccountUserId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
})
}
await tx.insert(permissions).values(permissionRows)
if (!skipDefaultWorkflow) {
await tx.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
}
logger.info(
skipDefaultWorkflow
? `Created ${workspaceMode} workspace ${workspaceId} for user ${userId}`
: `Created ${workspaceMode} workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}`
)
})
} catch (error) {
logger.error(`Failed to create workspace ${workspaceId}:`, error)
throw error
}
try {
PlatformEvents.workspaceCreated({
workspaceId,
userId,
name,
})
} catch {
// Telemetry should not fail the operation
}
const invitePolicy = await getWorkspaceInvitePolicy({
organizationId,
workspaceMode,
billedAccountUserId: committedBilledAccountUserId,
ownerId: userId,
})
return {
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId: committedBilledAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
role: 'owner',
permissions: 'admin',
...resolveInviteFlags(invitePolicy, committedBilledAccountUserId === userId),
}
}
async function migrateExistingWorkflows(userId: string, workspaceId: string) {
const orphanedWorkflows = await db
.select({ id: workflow.id })
@@ -0,0 +1,350 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
acceptEnterpriseOwnerClaimContract,
type EnterpriseOwnerClaimDetails,
} from '@/lib/api/contracts/enterprise-owner-claims'
import { client, useSession } from '@/lib/auth/auth-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useEnterpriseOwnerClaimDetails } from '@/hooks/queries/enterprise-owner-claims'
interface EnterpriseOwnerClaimProps {
registrationDisabled: boolean
}
function authLink(path: '/login' | '/signup', callbackUrl: string): string {
return buildAuthCrossLink(path, { callbackUrl, isInviteFlow: true })
}
function apiErrorCode(error: unknown): string {
if (!(error instanceof ApiClientError) || !error.body || typeof error.body !== 'object') {
return 'server-error'
}
const code = (error.body as { error?: unknown }).error
return typeof code === 'string' ? code : 'server-error'
}
function apiErrorMessage(error: unknown): string | null {
if (!(error instanceof ApiClientError) || !error.body || typeof error.body !== 'object') {
return null
}
const message = (error.body as { message?: unknown }).message
return typeof message === 'string' ? message : null
}
function claimSummary(details: EnterpriseOwnerClaimDetails) {
const workspacePreview = details.workspacePreview
const workspaces = workspacePreview?.workspacesToMove ?? []
const workspaceCopy = workspacePreview?.createsDefaultWorkspace
? 'A new personal workspace will be created, then moved into the organization after Enterprise is active.'
: workspaces.length > 0
? `${workspaces
.slice(0, 3)
.map((workspace) => workspace.name)
.join(
', '
)}${workspaces.length > 3 ? ` and ${workspaces.length - 3} more` : ''} will move into the organization after Enterprise is active.`
: null
return (
<span className='block space-y-3 text-left'>
<span className='block text-pretty text-center'>
Accept to become the owner of <strong>{details.organizationName}</strong>. Billing starts
only after you accept.
</span>
<span className='block rounded-lg border border-[var(--border-1)] p-4 text-sm tabular-nums'>
<span className='flex justify-between gap-4'>
<span>Invoice</span>
<strong>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(details.invoiceAmountUsd)}{' '}
/ {details.billingInterval}
</strong>
</span>
<span className='mt-2 flex justify-between gap-4'>
<span>Seats</span>
<strong>{details.seats.toLocaleString()}</strong>
</span>
{details.invitations > 0 && (
<span className='mt-2 flex justify-between gap-4'>
<span>People invited after activation</span>
<strong>{details.invitations.toLocaleString()}</strong>
</span>
)}
</span>
{workspaceCopy && (
<span className='block text-pretty text-center text-sm'>{workspaceCopy}</span>
)}
{details.acceptanceReview && !details.acceptanceReview.canAccept && (
<span className='block text-pretty rounded-lg border border-[var(--border-1)] p-3 text-center text-sm'>
{details.acceptanceReview.reason}
</span>
)}
</span>
)
}
export default function EnterpriseOwnerClaim({ registrationDisabled }: EnterpriseOwnerClaimProps) {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const claimId = params.id as string
const storageKey = `enterpriseOwnerClaimToken:${claimId}`
const tokenFromQuery = searchParams.get('token') || null
const { data: session, isPending: sessionPending } = useSession()
const [storedToken, setStoredToken] = useState<string | null | undefined>(undefined)
const [accepting, setAccepting] = useState(false)
const [acceptedClaim, setAcceptedClaim] = useState<EnterpriseOwnerClaimDetails['status']>()
const [actionError, setActionError] = useState<{ code: string; message: string }>()
const token = tokenFromQuery ?? storedToken ?? null
const tokenResolved = tokenFromQuery !== null || storedToken !== undefined
useEffect(() => {
if (tokenFromQuery) {
sessionStorage.setItem(storageKey, tokenFromQuery)
setStoredToken(tokenFromQuery)
window.history.replaceState(null, '', window.location.pathname)
return
}
setStoredToken(sessionStorage.getItem(storageKey))
}, [storageKey, tokenFromQuery])
const detailsQuery = useEnterpriseOwnerClaimDetails(claimId, token, session?.user?.id ?? null, {
enabled: Boolean(session?.user && tokenResolved),
})
const callbackUrl = `/enterprise/claim/${claimId}${token ? `?token=${encodeURIComponent(token)}` : ''}`
if (!session?.user && !sessionPending) {
return (
<InviteLayout>
<InviteStatusCard
type='login'
title="You're invited to own an Enterprise organization"
description={
registrationDisabled
? 'Sign in with the invited email to review the Enterprise setup.'
: 'Create your Sim account with the invited email, then review and activate the Enterprise setup.'
}
icon='userPlus'
actions={[
...(registrationDisabled
? []
: [
{
label: 'Create an account',
onClick: () => router.push(authLink('/signup', callbackUrl)),
},
]),
{
label: 'I already have an account',
onClick: () => router.push(authLink('/login', callbackUrl)),
},
{ label: 'Return to Home', onClick: () => router.push('/') },
]}
/>
</InviteLayout>
)
}
if (sessionPending || (session?.user && (!tokenResolved || detailsQuery.isPending))) {
return (
<InviteLayout>
<InviteStatusCard type='loading' title='' description='Loading Enterprise invitation...' />
</InviteLayout>
)
}
if (!token) {
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Invalid invitation'
description='The owner invitation link is missing its secure token.'
icon='error'
actions={[{ label: 'Return to Home', onClick: () => router.push('/') }]}
/>
</InviteLayout>
)
}
const queryErrorCode = detailsQuery.error ? apiErrorCode(detailsQuery.error) : null
const error =
actionError ??
(!acceptedClaim && queryErrorCode
? {
code: queryErrorCode,
message:
apiErrorMessage(detailsQuery.error) ??
(queryErrorCode === 'email-mismatch'
? 'This invitation was sent to a different email address.'
: queryErrorCode === 'email-unverified'
? 'Verify the invited email, then return to this owner invitation.'
: 'This Enterprise invitation is invalid or unavailable.'),
}
: null)
if (error) {
const wrongAccount = error.code === 'email-mismatch'
return (
<InviteLayout>
<InviteStatusCard
type={wrongAccount ? 'warning' : 'error'}
title={wrongAccount ? 'Wrong account' : 'Enterprise invitation error'}
description={error.message}
icon={wrongAccount ? 'userPlus' : 'error'}
actions={[
...(wrongAccount
? [
{
label: 'Sign in with a different account',
onClick: async () => {
await client.signOut()
router.push(authLink('/login', callbackUrl))
},
},
]
: [{ label: 'Try again', onClick: () => window.location.reload() }]),
{ label: 'Return to Home', onClick: () => router.push('/') },
]}
/>
</InviteLayout>
)
}
const details = detailsQuery.data
if (!details) return null
if (details.status === 'expired') {
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Invitation expired'
description='Ask the Admin team to send a new Enterprise owner invitation.'
icon='error'
actions={[{ label: 'Return to Home', onClick: () => router.push('/') }]}
/>
</InviteLayout>
)
}
if (details.status === 'revoked') {
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Invitation revoked'
description='This Enterprise owner invitation is no longer active. Ask the Admin team to send a new invitation if needed.'
icon='error'
actions={[{ label: 'Return to Home', onClick: () => router.push('/') }]}
/>
</InviteLayout>
)
}
if (acceptedClaim || details.workspacePreview === null) {
const applied = acceptedClaim === 'applied' || details.status === 'applied'
return (
<InviteLayout>
<InviteStatusCard
type={details.status === 'failed' ? 'error' : 'success'}
title={applied ? 'Enterprise is active' : 'Enterprise activation started'}
description={
details.status === 'failed'
? details.error || 'Activation needs attention from the Admin team.'
: applied
? 'Enterprise entitlement is applied. Sign in again to enter the organization.'
: 'Stripe activation is in progress. Workspaces and teammate invitations remain locked until the verified entitlement is applied; you will be signed out when that happens.'
}
icon={details.status === 'failed' ? 'error' : 'success'}
actions={
applied
? [
{
label: 'Sign in to Enterprise',
onClick: async () => {
await client.signOut()
router.push(authLink('/login', '/workspace'))
},
},
]
: [
{
label: 'Check status',
onClick: async () => {
const refreshed = await detailsQuery.refetch()
if (refreshed.data) setAcceptedClaim(refreshed.data.status)
},
},
{ label: 'Return to Home', onClick: () => router.push('/') },
]
}
/>
</InviteLayout>
)
}
const accept = async () => {
setAccepting(true)
setActionError(undefined)
try {
const response = await requestJson(acceptEnterpriseOwnerClaimContract, {
params: { id: claimId },
body: {
token,
disclosedWorkspaceIds:
details.workspacePreview?.workspacesToMove.map((row) => row.id) ?? [],
disclosedCreatesDefaultWorkspace:
details.workspacePreview?.createsDefaultWorkspace ?? false,
},
})
setAcceptedClaim(response.claim.status)
} catch (acceptError) {
const code = apiErrorCode(acceptError)
const fallbackByCode: Record<string, string> = {
'disclosure-outdated':
'Your personal workspaces changed. Reload and review the updated list before accepting.',
'already-in-organization':
'This account already belongs to an organization and cannot become this organization owner.',
'insufficient-seats': 'The selected plan no longer has enough seats for this setup.',
'workspace-limit': 'This account has too many personal workspaces to migrate safely.',
revoked: 'This Enterprise owner invitation has been revoked.',
'workspace-invitation-limit':
'The teammate invitation batch covers too many workspaces. Ask the Admin team to adjust the setup.',
}
setActionError({
code,
message:
apiErrorMessage(acceptError) ??
fallbackByCode[code] ??
'Enterprise activation could not be started. Please try again.',
})
if (code === 'disclosure-outdated') await detailsQuery.refetch()
} finally {
setAccepting(false)
}
}
return (
<InviteLayout>
<InviteStatusCard
type='invitation'
title='Enterprise owner invitation'
description={claimSummary(details)}
icon='users'
actions={[
{
label: 'Accept and activate',
onClick: accept,
disabled: accepting || details.acceptanceReview?.canAccept === false,
loading: accepting,
},
{ label: 'Not now', onClick: () => router.push('/') },
]}
/>
</InviteLayout>
)
}
@@ -0,0 +1,20 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import EnterpriseOwnerClaim from '@/app/enterprise/claim/[id]/enterprise-owner-claim'
import InviteLoading from '@/app/invite/[id]/loading'
export const metadata: Metadata = {
title: 'Enterprise invitation',
robots: { index: false },
}
export const dynamic = 'force-dynamic'
export default function EnterpriseOwnerClaimPage() {
return (
<Suspense fallback={<InviteLoading />}>
<EnterpriseOwnerClaim registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
}
@@ -0,0 +1,40 @@
import { Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EnterpriseOwnerInvitationEmailProps {
organizationName: string
inviteLink: string
expiresInDays: number
}
export function EnterpriseOwnerInvitationEmail({
organizationName,
inviteLink,
expiresInDays,
}: EnterpriseOwnerInvitationEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout
preview={`Activate ${organizationName}'s Enterprise plan on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.greeting}>Hello,</Text>
<Text style={baseStyles.paragraph}>
You were selected as the owner of <EmailStrong>{organizationName}</EmailStrong> on{' '}
{brand.name}.
</Text>
<Text style={baseStyles.paragraph}>
Review and accept the invitation to create the organization and start its Enterprise plan.
Billing does not begin until you accept.
</Text>
<EmailButton href={inviteLink}>Review Enterprise invitation</EmailButton>
<div style={baseStyles.divider} />
<Text style={baseStyles.footnote}>
This invitation expires in {expiresInDays} days. If you did not expect it, you can ignore
this email.
</Text>
</EmailLayout>
)
}
@@ -1,4 +1,5 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { EnterpriseOwnerInvitationEmail } from './enterprise-owner-invitation-email'
export { InvitationEmail } from './invitation-email'
export { WorkspaceAddedEmail } from './workspace-added-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
+11
View File
@@ -21,6 +21,7 @@ import {
} from '@/components/emails/billing'
import {
BatchInvitationEmail,
EnterpriseOwnerInvitationEmail,
InvitationEmail,
WorkspaceAddedEmail,
WorkspaceInvitationEmail,
@@ -97,6 +98,16 @@ export async function renderBatchInvitationEmail(
)
}
export async function renderEnterpriseOwnerInvitationEmail(
organizationName: string,
inviteLink: string,
expiresInDays: number
): Promise<string> {
return await render(
EnterpriseOwnerInvitationEmail({ organizationName, inviteLink, expiresInDays })
)
}
export async function renderHelpConfirmationEmail(
type: 'bug' | 'feedback' | 'feature_request' | 'other',
attachmentCount = 0
+3
View File
@@ -10,6 +10,7 @@ export type EmailSubjectType =
| 'reset-password'
| 'existing-account'
| 'invitation'
| 'enterprise-owner-invitation'
| 'batch-invitation'
| 'workspace-added'
| 'enterprise-subscription'
@@ -47,6 +48,8 @@ export function getEmailSubject(type: EmailSubjectType): string {
return `Sign-up attempt with your ${brandName} email`
case 'invitation':
return `You've been invited to join a team on ${brandName}`
case 'enterprise-owner-invitation':
return `Activate your Enterprise organization on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
case 'workspace-added':
@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { getEnterpriseOwnerClaimContract } from '@/lib/api/contracts/enterprise-owner-claims'
export const ENTERPRISE_OWNER_CLAIM_DETAILS_STALE_TIME = 30 * 1000
export const enterpriseOwnerClaimKeys = {
all: ['enterprise-owner-claims'] as const,
details: () => [...enterpriseOwnerClaimKeys.all, 'detail'] as const,
detail: (claimId: string, token: string | null, viewerId: string | null) =>
[...enterpriseOwnerClaimKeys.details(), claimId, token ?? '', viewerId ?? ''] as const,
}
export function useEnterpriseOwnerClaimDetails(
claimId: string | undefined,
token: string | null,
viewerId: string | null,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: enterpriseOwnerClaimKeys.detail(claimId ?? '', token, viewerId),
queryFn: ({ signal }) =>
requestJson(getEnterpriseOwnerClaimContract, {
params: { id: claimId as string },
query: { token: token as string },
signal,
}),
enabled: Boolean(claimId && token) && (options?.enabled ?? true),
staleTime: ENTERPRISE_OWNER_CLAIM_DETAILS_STALE_TIME,
retry: false,
})
}
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
import { member, organization, subscription, user, userStats, workspace } from '@sim/db/schema'
import { member, organization, user, userStats } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -72,11 +72,7 @@ vi.mock('@/lib/billing/enterprise-outbox', () => ({
}))
vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn() }))
import {
addDashboardOrganizationMember,
grantDashboardOrganizationBalance,
grantDashboardUserBalance,
} from '@/lib/admin/dashboard'
import { grantDashboardOrganizationBalance, grantDashboardUserBalance } from '@/lib/admin/dashboard'
/** The values object passed to the nth `update(...).set(...)` call. */
const updateSetValues = (index = 0): Record<string, unknown> =>
@@ -207,169 +203,3 @@ describe('grantDashboardUserBalance', () => {
expect(mocks.recordAudit).not.toHaveBeenCalled()
})
})
describe('addDashboardOrganizationMember', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mocks.billingSubscriptions = []
mocks.idempotencyCalls = []
mocks.ensureMembership.mockReset()
mocks.transferMembership.mockReset()
mocks.moveWorkspace.mockReset()
})
it('rejects an existing member inside the transaction before touching their cap', async () => {
queueTableRows(subscription, [{ plan: 'enterprise' }])
mocks.ensureMembership.mockResolvedValue({
success: true,
memberId: 'member-1',
alreadyMember: true,
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
})
await expect(
addDashboardOrganizationMember(
'org-1',
{
userId: 'user-1',
role: 'member',
usageLimitDollars: null,
personalWorkspaceIds: [],
},
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
)
).rejects.toThrow('User is already a member')
expect(mocks.setMemberLimit).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})
it('moves every selected workspace through the invitation-aware service after adding a member', async () => {
queueTableRows(workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
queueTableRows(subscription, [{ plan: 'enterprise' }])
mocks.ensureMembership.mockResolvedValue({
success: true,
memberId: 'member-new',
alreadyMember: false,
billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false },
})
mocks.moveWorkspace.mockResolvedValue({})
const result = await addDashboardOrganizationMember(
'org-1',
{
userId: 'user-1',
role: 'member',
personalWorkspaceIds: ['workspace-1', 'workspace-2'],
},
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
)
expect(mocks.moveWorkspace).toHaveBeenNthCalledWith(1, {
workspaceId: 'workspace-1',
destinationOrganizationId: 'org-1',
adminEmail: 'admin@sim.ai',
expectedOwnerId: 'user-1',
})
expect(mocks.moveWorkspace).toHaveBeenNthCalledWith(2, {
workspaceId: 'workspace-2',
destinationOrganizationId: 'org-1',
adminEmail: 'admin@sim.ai',
expectedOwnerId: 'user-1',
})
expect(result).toEqual({
memberId: 'member-new',
transferredFromOrganizationId: null,
workspaceMoves: [
{ workspaceId: 'workspace-1', success: true },
{ workspaceId: 'workspace-2', success: true },
],
})
})
it('uses the canonical transfer service and reports each selected workspace move', async () => {
queueTableRows(workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
queueTableRows(member, [{ id: 'member-old', organizationId: 'org-old' }])
mocks.transferMembership.mockResolvedValue({
success: true,
memberId: 'member-new',
workspaceAccessRevoked: 2,
credentialMembershipsRevoked: 1,
pendingInvitationsCancelled: 0,
usageCaptured: 3,
})
mocks.moveWorkspace
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('Workspace changed concurrently'))
const result = await addDashboardOrganizationMember(
'org-new',
{
userId: 'user-1',
role: 'admin',
usageLimitDollars: 25,
personalWorkspaceIds: ['workspace-1', 'workspace-2'],
},
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
)
expect(mocks.transferMembership).toHaveBeenCalledWith({
userId: 'user-1',
sourceOrganizationId: 'org-old',
destinationOrganizationId: 'org-new',
role: 'admin',
usageLimitDollars: 25,
setBy: 'admin-1',
})
expect(mocks.moveWorkspace).toHaveBeenCalledTimes(2)
expect(result).toEqual({
memberId: 'member-new',
transferredFromOrganizationId: 'org-old',
workspaceMoves: [
{ workspaceId: 'workspace-1', success: true },
{
workspaceId: 'workspace-2',
success: false,
error: 'Workspace changed concurrently',
},
],
})
expect(mocks.reconcileSeats).toHaveBeenCalledTimes(2)
expect(mocks.recordAudit).toHaveBeenCalledTimes(2)
})
it('uses the expected owner guard for an administrator-selected subset', async () => {
// The attachability query is scoped to the selected ids, so it returns only
// `workspace-1` even though the user owns more.
queueTableRows(workspace, [{ id: 'workspace-1' }])
queueTableRows(member, [{ id: 'member-old', organizationId: 'org-old' }])
mocks.transferMembership.mockResolvedValue({
success: true,
memberId: 'member-new',
workspaceAccessRevoked: 0,
credentialMembershipsRevoked: 0,
pendingInvitationsCancelled: 0,
usageCaptured: 0,
})
mocks.moveWorkspace.mockResolvedValue({})
const result = await addDashboardOrganizationMember(
'org-new',
{
userId: 'user-1',
role: 'member',
personalWorkspaceIds: ['workspace-1'],
},
{ id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
)
expect(mocks.moveWorkspace).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
destinationOrganizationId: 'org-new',
adminEmail: 'admin@sim.ai',
expectedOwnerId: 'user-1',
})
expect(result.workspaceMoves).toEqual([{ workspaceId: 'workspace-1', success: true }])
})
})
@@ -6,6 +6,7 @@ import {
permissions,
subscription,
usageLog,
user,
workspace,
} from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
@@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({
provisionings: new Map(),
resolveMetadataIntent: vi.fn(),
enqueueOutboxEvent: vi.fn(),
countPendingSeatInvitations: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
@@ -64,19 +66,107 @@ vi.mock('@/lib/billing/organizations/membership', () => ({
transferOrganizationOwnership: vi.fn(),
}))
vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: vi.fn() }))
vi.mock('@/lib/billing/validation/seat-management', () => ({
countPendingSeatInvitations: mocks.countPendingSeatInvitations,
}))
vi.mock('@/lib/core/idempotency/transaction', () => ({
executeTransactionallyIdempotent: vi.fn(),
}))
vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.enqueueOutboxEvent }))
import {
getDashboardMemberTransferPreflight,
getDashboardOrganization,
listDashboardOrganizations,
toDashboardConfigurationUpdate,
updateDashboardEnterpriseBillingTerms,
updateDashboardEnterpriseSeats,
updateDashboardOrganizationLimits,
} from '@/lib/admin/dashboard'
describe('getDashboardMemberTransferPreflight', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('pages workspace choices and exposes an empty default selection above the exact cap', async () => {
queueTableRows(organization, [{ id: 'org-destination' }])
queueTableRows(user, [
{
id: 'user-1',
name: 'User',
email: 'user@example.com',
memberId: null,
role: null,
organizationId: null,
organizationName: null,
},
])
queueTableRows(workspace, [{ value: 75 }])
queueTableRows(workspace, [
{ id: 'workspace-51', name: 'Matching workspace', archivedAt: null },
])
queueTableRows(workspace, [
{
id: 'workspace-1',
name: 'First eligible workspace',
archivedAt: null,
total: 1_205,
},
])
const result = await getDashboardMemberTransferPreflight('org-destination', 'user-1', {
search: 'matching',
limit: 25,
offset: 50,
})
expect(result.personalWorkspaces).toEqual([
{ id: 'workspace-51', name: 'Matching workspace', archived: false },
])
expect(result.workspacePagination).toEqual({
total: 75,
limit: 25,
offset: 50,
hasMore: true,
})
expect(result.workspaceSelection).toEqual({
totalEligible: 1_205,
defaultSelectedIds: [],
defaultSelectedWorkspaces: [],
includesAllEligible: false,
limit: 1_000,
})
expect(dbChainMockFns.limit).toHaveBeenCalledWith(1_001)
})
})
describe('updateDashboardEnterpriseSeats', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('refuses to reduce capacity below members plus live pending seat reservations', async () => {
queueTableRows(subscription, [
{ id: 'sub-1', plan: 'enterprise', status: 'active', metadata: { seats: 10 } },
])
queueTableRows(member, [{ value: 5 }])
mocks.countPendingSeatInvitations.mockResolvedValue(2)
await expect(
updateDashboardEnterpriseSeats('org-1', 6, {
id: 'admin-1',
name: 'Admin',
email: 'admin@example.com',
})
).rejects.toThrow('below 7 occupied or reserved seats')
expect(mocks.enqueueOutboxEvent).not.toHaveBeenCalled()
})
})
afterAll(() => {
resetDbChainMock()
})
@@ -177,6 +267,86 @@ describe('listDashboardOrganizations', () => {
expect(dbChainMockFns.select).toHaveBeenCalledTimes(6)
expect(dbChainMockFns.selectDistinctOn).toHaveBeenCalledTimes(1)
})
it('preserves the frozen baseline for an Enterprise subscription using its Stripe period', async () => {
queueTableRows(organization, [{ total: 1 }])
queueTableRows(organization, [
{ id: 'org-1', name: 'One', orgUsageLimit: '100', creditBalance: '0' },
])
queueTableRows(member, [
{
organizationId: 'org-1',
memberCount: 1,
ownerId: 'owner-1',
ownerName: 'Owner',
ownerEmail: 'owner@example.com',
},
])
queueTableRows(permissions, [])
queueTableRows(subscription, [
{
id: 'sub-1',
referenceId: 'org-1',
plan: 'enterprise',
status: 'active',
billingInterval: 'month',
periodStart: new Date('2026-08-01T00:00:00.000Z'),
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
metadata: { invoiceAmountCents: 10_000, seats: 1 },
},
])
queueTableRows(usageLog, [{ organizationId: 'org-1', cost: '2.5', workflowRuns: 3 }])
queueTableRows(member, [{ organizationId: 'org-1', cost: '1.5' }])
const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 })
expect(result.data[0]).toMatchObject({
reportingPeriod: { source: 'stripe' },
usage: { usedDollars: 4, workflowRuns: 3 },
})
})
it('does not inject the frozen Stripe-period baseline into a custom reporting period', async () => {
queueTableRows(organization, [{ total: 1 }])
queueTableRows(organization, [
{ id: 'org-1', name: 'One', orgUsageLimit: '100', creditBalance: '0' },
])
queueTableRows(member, [
{
organizationId: 'org-1',
memberCount: 1,
ownerId: 'owner-1',
ownerName: 'Owner',
ownerEmail: 'owner@example.com',
},
])
queueTableRows(permissions, [])
queueTableRows(subscription, [
{
id: 'sub-1',
referenceId: 'org-1',
plan: 'enterprise',
status: 'active',
billingInterval: 'year',
periodStart: new Date('2026-08-01T00:00:00.000Z'),
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
metadata: {
invoiceAmountCents: 10_000,
seats: 1,
reportingPeriodAnchorDate: '2026-01-01',
},
},
])
queueTableRows(usageLog, [{ organizationId: 'org-1', cost: '2.5', workflowRuns: 3 }])
queueTableRows(member, [{ organizationId: 'org-1', cost: '1.5' }])
const result = await listDashboardOrganizations({ search: '', limit: 50, offset: 0 })
expect(result.data[0]).toMatchObject({
reportingPeriod: { source: 'reporting', anchorDate: '2026-01-01', interval: 'year' },
usage: { usedDollars: 2.5, workflowRuns: 3 },
})
})
})
describe('getDashboardOrganization', () => {
+231 -234
View File
@@ -12,7 +12,6 @@ import {
userStats,
workspace,
} from '@sim/db/schema'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import {
@@ -37,14 +36,12 @@ import {
} from '@/lib/admin/organization-economics'
import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
import { getBillingConcurrencyLimit } from '@/lib/billing/concurrency-limits'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import {
type ResolvedUsagePeriod,
resolveEnterpriseReportingPeriod,
resolveSubscriptionUsagePeriod,
resolveSubscriptionUsagePeriodOrDefault,
} from '@/lib/billing/core/reporting-period'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
ENTERPRISE_METADATA_SYNC_EVENT_TYPE,
@@ -63,11 +60,9 @@ import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/bill
import { setOrgMemberUsageLimit } from '@/lib/billing/organizations/member-limits'
import {
acquireOrganizationMutationLock,
ensureUserInOrganizationTx,
getOrganizationTransferCredentialDependencies,
removeUserFromOrganization,
transferOrganizationOwnership,
transferUserBetweenOrganizations,
} from '@/lib/billing/organizations/membership'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import {
@@ -77,11 +72,11 @@ import {
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { toDecimal } from '@/lib/billing/utils/decimal'
import { countPendingSeatInvitations } from '@/lib/billing/validation/seat-management'
import { env } from '@/lib/core/config/env'
import { executeTransactionallyIdempotent } from '@/lib/core/idempotency/transaction'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import type { DbOrTx } from '@/lib/db/types'
import { moveWorkspaceToOrganization } from '@/lib/workspaces/admin-move'
import { ownedAttachableWorkspacesWhere } from '@/lib/workspaces/organization-workspaces'
interface PaginationInput {
@@ -90,6 +85,8 @@ interface PaginationInput {
offset: number
}
const MAX_ADMIN_MEMBER_WORKSPACE_SELECTION = 1_000
export interface AdminMutationActor {
id: string | null
name: string
@@ -181,6 +178,7 @@ interface DashboardOrganizationSummaryInput {
provisioning: EnterpriseProvisioningView | null
owner: { id: string; name: string; email: string } | null
usageDollars: number
workflowRuns: number
usagePeriod: ResolvedUsagePeriod
}
@@ -192,19 +190,14 @@ interface DashboardOrganizationUsageContext {
interface DashboardOrganizationUsage {
total: number
byUser: Map<string, number>
workflowRuns: number
workflowRunsByUser: Map<string, number>
}
function resolveDashboardUsagePeriod(
latestSubscription: typeof subscription.$inferSelect | null
): ResolvedUsagePeriod {
return (
resolveSubscriptionUsagePeriod(latestSubscription) ?? {
...defaultBillingPeriod(),
source: 'default' as const,
anchorDate: null,
interval: null,
}
)
return resolveSubscriptionUsagePeriodOrDefault(latestSubscription ?? {})
}
async function getDashboardOrganizationUsage(
@@ -213,7 +206,12 @@ async function getDashboardOrganizationUsage(
): Promise<Map<string, DashboardOrganizationUsage>> {
const result = new Map<string, DashboardOrganizationUsage>()
for (const context of contexts) {
result.set(context.organizationId, { total: 0, byUser: new Map() })
result.set(context.organizationId, {
total: 0,
byUser: new Map(),
workflowRuns: 0,
workflowRunsByUser: new Map(),
})
}
if (contexts.length === 0) return result
@@ -242,6 +240,10 @@ async function getDashboardOrganizationUsage(
.select({
organizationId: usageLog.billingEntityId,
cost: sql<string>`coalesce(sum(${usageLog.cost}), 0)`,
workflowRuns:
sql<number>`count(distinct ${usageLog.executionId}) filter (where ${usageLog.source} = 'workflow')`.mapWith(
Number
),
})
.from(usageLog)
.where(ledgerPeriodWhere)
@@ -249,7 +251,10 @@ async function getDashboardOrganizationUsage(
for (const row of ledgerTotals) {
if (!row.organizationId) continue
const usage = result.get(row.organizationId)
if (usage) usage.total += Number(row.cost)
if (usage) {
usage.total += Number(row.cost)
usage.workflowRuns += row.workflowRuns
}
}
const legacyOrganizationIds = contexts
@@ -278,6 +283,10 @@ async function getDashboardOrganizationUsage(
organizationId: usageLog.billingEntityId,
userId: usageLog.userId,
cost: sql<string>`coalesce(sum(${usageLog.cost}), 0)`,
workflowRuns:
sql<number>`count(distinct ${usageLog.executionId}) filter (where ${usageLog.source} = 'workflow')`.mapWith(
Number
),
})
.from(usageLog)
.where(
@@ -294,6 +303,11 @@ async function getDashboardOrganizationUsage(
const amount = Number(row.cost)
usage.total += amount
usage.byUser.set(row.userId, (usage.byUser.get(row.userId) ?? 0) + amount)
usage.workflowRuns += row.workflowRuns
usage.workflowRunsByUser.set(
row.userId,
(usage.workflowRunsByUser.get(row.userId) ?? 0) + row.workflowRuns
)
}
const legacyOrganizationIds = contexts
@@ -334,7 +348,7 @@ const historicalUsageWorkspace = alias(workspace, 'historical_usage_workspace')
async function getHistoricalActorUsage(
organizationId: string,
period: ResolvedUsagePeriod
): Promise<{ usedDollars: number; actorCount: number }> {
): Promise<{ usedDollars: number; usedCredits: number; workflowRuns: number; actorCount: number }> {
const currentMember = db
.select({ value: sql`1` })
.from(historicalUsageMember)
@@ -363,6 +377,10 @@ async function getHistoricalActorUsage(
const [row] = await db
.select({
usedDollars: sql<string>`coalesce(sum(${usageLog.cost}), 0)`,
workflowRuns:
sql<number>`count(distinct ${usageLog.executionId}) filter (where ${usageLog.source} = 'workflow')`.mapWith(
Number
),
actorCount: countDistinct(usageLog.userId),
})
.from(usageLog)
@@ -380,8 +398,11 @@ async function getHistoricalActorUsage(
notExists(currentCollaborator)
)
)
const usedDollars = Number(row?.usedDollars ?? 0)
return {
usedDollars: Number(row?.usedDollars ?? 0),
usedDollars,
usedCredits: dollarsToCredits(usedDollars),
workflowRuns: row?.workflowRuns ?? 0,
actorCount: row?.actorCount ?? 0,
}
}
@@ -402,6 +423,7 @@ function buildDashboardOrganizationSummary({
provisioning,
owner,
usageDollars,
workflowRuns,
usagePeriod,
}: DashboardOrganizationSummaryInput) {
const metadata = metadataRecord(latestSubscription?.metadata)
@@ -467,7 +489,13 @@ function buildDashboardOrganizationSummary({
currentEnd: reportingPeriod.end.toISOString(),
source: reportingPeriod.source,
},
usage: { usedDollars: Math.max(0, usageDollars), limitDollars: usageLimitDollars },
usage: {
usedDollars: Math.max(0, usageDollars),
limitDollars: usageLimitDollars,
usedCredits: dollarsToCredits(Math.max(0, usageDollars)),
limitCredits: dollarsToCredits(usageLimitDollars),
workflowRuns,
},
provisioning: provisioning ? toDashboardProvisioning(provisioning) : null,
subscription: latestSubscription,
}
@@ -600,6 +628,10 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn
.select({
userId: usageLog.billingEntityId,
cost: sql<string>`coalesce(sum(${usageLog.cost}), 0)`,
workflowRuns:
sql<number>`count(distinct ${usageLog.executionId}) filter (where ${usageLog.source} = 'workflow')`.mapWith(
Number
),
})
.from(usageLog)
.where(
@@ -622,7 +654,9 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn
.groupBy(usageLog.billingEntityId)
const personalUsage = new Map(
personalLedgerRows.flatMap((row) =>
row.userId ? ([[row.userId, Number(row.cost)]] as const) : []
row.userId
? ([[row.userId, { dollars: Number(row.cost), workflowRuns: row.workflowRuns }]] as const)
: []
)
)
const legacyPersonalIds = personalUserIds.filter(
@@ -634,23 +668,37 @@ export async function listDashboardUsers({ search, limit, offset }: PaginationIn
.from(userStats)
.where(inArray(userStats.userId, legacyPersonalIds))
for (const row of baselineRows) {
personalUsage.set(row.userId, (personalUsage.get(row.userId) ?? 0) + Number(row.cost ?? 0))
const current = personalUsage.get(row.userId) ?? { dollars: 0, workflowRuns: 0 }
personalUsage.set(row.userId, {
...current,
dollars: current.dollars + Number(row.cost ?? 0),
})
}
}
return {
data: rows.map((row) => ({
id: row.id,
name: row.name,
email: row.email,
activeOrganization:
row.organizationId && row.organizationName
? { id: row.organizationId, name: row.organizationName }
: null,
usageDollars: row.organizationId
? (organizationUsage.get(row.organizationId)?.byUser.get(row.id) ?? 0)
: (personalUsage.get(row.id) ?? 0),
})),
data: rows.map((row) => {
const organization = row.organizationId
? organizationUsage.get(row.organizationId)
: undefined
const usageDollars = row.organizationId
? (organization?.byUser.get(row.id) ?? 0)
: (personalUsage.get(row.id)?.dollars ?? 0)
return {
id: row.id,
name: row.name,
email: row.email,
activeOrganization:
row.organizationId && row.organizationName
? { id: row.organizationId, name: row.organizationName }
: null,
usageDollars,
usageCredits: dollarsToCredits(usageDollars),
workflowRuns: row.organizationId
? (organization?.workflowRunsByUser.get(row.id) ?? 0)
: (personalUsage.get(row.id)?.workflowRuns ?? 0),
}
}),
pagination: {
total: totalRow[0]?.total ?? 0,
limit,
@@ -708,6 +756,7 @@ async function getDashboardOrganizationSummary(organizationId: string) {
provisioning: provisionings.get(organizationId) ?? null,
owner: owner ?? null,
usageDollars: usage.get(organizationId)?.total ?? 0,
workflowRuns: usage.get(organizationId)?.workflowRuns ?? 0,
usagePeriod: period,
}),
usagePeriod: period,
@@ -828,6 +877,7 @@ export async function listDashboardOrganizations({ search, limit, offset }: Pagi
provisioning: provisionings.get(org.id) ?? null,
owner,
usageDollars: usageByOrganization.get(org.id)?.total ?? 0,
workflowRuns: usageByOrganization.get(org.id)?.workflowRuns ?? 0,
usagePeriod: usagePeriodsByOrganization.get(org.id)!,
})
return summary
@@ -944,7 +994,9 @@ export async function getDashboardOrganization(
getHistoricalActorUsage(organizationId, usagePeriod),
])
const limits = new Map(limitRows.map((row) => [row.userId, Number(row.limit)]))
const usageByUser = usageByOrganization.get(organizationId)?.byUser ?? new Map<string, number>()
const organizationUsage = usageByOrganization.get(organizationId)
const usageByUser = organizationUsage?.byUser ?? new Map<string, number>()
const workflowRunsByUser = organizationUsage?.workflowRunsByUser ?? new Map<string, number>()
const workspaceTotal = workspaceCountRows[0]?.value ?? 0
return {
...base,
@@ -957,12 +1009,16 @@ export async function getDashboardOrganization(
...row,
usageLimitDollars: limits.get(row.userId) ?? null,
usageDollars: usageByUser.get(row.userId) ?? 0,
usageCredits: dollarsToCredits(usageByUser.get(row.userId) ?? 0),
workflowRuns: workflowRunsByUser.get(row.userId) ?? 0,
})),
externalCollaborators: externalRows.map((row) => ({
...row,
workspaceCount: row.workspaceCount,
usageLimitDollars: limits.get(row.userId) ?? null,
usageDollars: usageByUser.get(row.userId) ?? 0,
usageCredits: dollarsToCredits(usageByUser.get(row.userId) ?? 0),
workflowRuns: workflowRunsByUser.get(row.userId) ?? 0,
})),
workspaces: workspaceRows,
memberPagination: {
@@ -990,6 +1046,7 @@ export async function getDashboardOrganization(
id: subscriptionRow.id,
plan: subscriptionRow.plan,
status: subscriptionRow.status,
cancelAtPeriodEnd: subscriptionRow.cancelAtPeriodEnd,
periodStart: subscriptionRow.periodStart?.toISOString() ?? null,
periodEnd: subscriptionRow.periodEnd?.toISOString() ?? null,
stripeSubscriptionId: subscriptionRow.stripeSubscriptionId,
@@ -999,6 +1056,48 @@ export async function getDashboardOrganization(
}
}
export async function renameDashboardOrganization(
organizationId: string,
name: string,
actor: AdminMutationActor
) {
const normalizedName = name.trim()
if (!normalizedName || normalizedName.length > 120) {
throw new Error('Organization name must be between 1 and 120 characters')
}
const previousName = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [row] = await tx
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.for('update')
.limit(1)
if (!row) throw new Error('Organization not found')
if (row.name !== normalizedName) {
await tx
.update(organization)
.set({ name: normalizedName, updatedAt: new Date() })
.where(eq(organization.id, organizationId))
}
return row.name
})
if (previousName !== normalizedName) {
recordAudit({
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
resourceName: normalizedName,
description: `Admin renamed organization from ${previousName} to ${normalizedName}`,
metadata: { previousName, name: normalizedName },
})
}
}
export async function updateDashboardEnterpriseSeats(
organizationId: string,
seats: number,
@@ -1023,8 +1122,12 @@ export async function updateDashboardEnterpriseSeats(
.select({ value: count() })
.from(member)
.where(eq(member.organizationId, organizationId))
if (seats < (memberCountRow?.value ?? 0)) {
throw new Error('Seat capacity cannot be below current internal membership')
const pendingSeats = await countPendingSeatInvitations(organizationId, tx)
const requiredSeats = (memberCountRow?.value ?? 0) + pendingSeats
if (seats < requiredSeats) {
throw new Error(
`Seat capacity cannot be below ${requiredSeats} occupied or reserved seats (${memberCountRow?.value ?? 0} members and ${pendingSeats} pending invitations)`
)
}
await enqueueEnterpriseMetadataIntent(tx, {
subscriptionId: subscriptionRow.id,
@@ -1039,7 +1142,7 @@ export async function updateDashboardEnterpriseSeats(
action: AuditAction.ORG_SEAT_PROVISIONED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `Admin set Enterprise seat capacity to ${seats}`,
description: `Admin requested Enterprise seat capacity ${seats}`,
metadata: { seats },
})
}
@@ -1097,6 +1200,7 @@ export async function previewDashboardEnterpriseBillingTerms(
includeUserBreakdown: false,
})
const usedDollars = usage.get(organizationId)?.total ?? 0
const workflowRuns = usage.get(organizationId)?.workflowRuns ?? 0
const limitDollars = Number(org.orgUsageLimit ?? 0)
return {
reportingPeriod: {
@@ -1106,7 +1210,13 @@ export async function previewDashboardEnterpriseBillingTerms(
currentEnd: reportingPeriod.end.toISOString(),
source: reportingPeriod.source,
},
usage: { usedDollars, limitDollars },
usage: {
usedDollars,
limitDollars,
usedCredits: dollarsToCredits(usedDollars),
limitCredits: dollarsToCredits(limitDollars),
workflowRuns,
},
exceedsLimit: usedDollars > limitDollars,
}
}
@@ -1243,7 +1353,7 @@ export async function updateDashboardOrganizationLimits(
},
actor: AdminMutationActor
) {
await db.transaction(async (tx) => {
const providerBacked = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [org] = await tx
.select()
@@ -1310,7 +1420,7 @@ export async function updateDashboardOrganizationLimits(
}
},
})
return
return true
}
const [memberCountRow] = await tx
@@ -1344,6 +1454,7 @@ export async function updateDashboardOrganizationLimits(
})
.where(eq(subscription.id, subscriptionRow.id))
}
return false
})
recordAudit({
actorId: actor.id,
@@ -1352,7 +1463,9 @@ export async function updateDashboardOrganizationLimits(
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: 'Admin updated organization limits',
description: providerBacked
? 'Admin requested Enterprise organization-limit update'
: 'Admin updated organization limits',
metadata: values,
})
}
@@ -1578,35 +1691,62 @@ export async function grantDashboardUserBalance(
export async function getDashboardMemberTransferPreflight(
destinationOrganizationId: string,
userId: string
userId: string,
workspacePage: PaginationInput = { search: '', limit: 50, offset: 0 }
) {
const [[destination], [target], personalWorkspaces] = await Promise.all([
db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, destinationOrganizationId))
.limit(1),
db
.select({
id: user.id,
name: user.name,
email: user.email,
memberId: member.id,
role: member.role,
organizationId: member.organizationId,
organizationName: organization.name,
})
.from(user)
.leftJoin(member, eq(member.userId, user.id))
.leftJoin(organization, eq(organization.id, member.organizationId))
.where(eq(user.id, userId))
.limit(1),
db
.select({ id: workspace.id, name: workspace.name, archivedAt: workspace.archivedAt })
.from(workspace)
.where(ownedAttachableWorkspacesWhere({ userId, includeArchived: true }))
.orderBy(workspace.name, workspace.id),
])
const search = workspacePage.search.trim()
const limit = Math.min(Math.max(workspacePage.limit, 1), 250)
const offset = Math.max(workspacePage.offset, 0)
const allPersonalWorkspacesWhere = ownedAttachableWorkspacesWhere({
userId,
includeArchived: true,
})
const matchingPersonalWorkspacesWhere = and(
allPersonalWorkspacesWhere,
search ? or(eq(workspace.id, search), ilike(workspace.name, `%${search}%`)) : undefined
)
const [[destination], [target], personalWorkspaceCount, personalWorkspaces, selectionRows] =
await Promise.all([
db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, destinationOrganizationId))
.limit(1),
db
.select({
id: user.id,
name: user.name,
email: user.email,
memberId: member.id,
role: member.role,
organizationId: member.organizationId,
organizationName: organization.name,
})
.from(user)
.leftJoin(member, eq(member.userId, user.id))
.leftJoin(organization, eq(organization.id, member.organizationId))
.where(eq(user.id, userId))
.limit(1),
db.select({ value: count() }).from(workspace).where(matchingPersonalWorkspacesWhere),
db
.select({ id: workspace.id, name: workspace.name, archivedAt: workspace.archivedAt })
.from(workspace)
.where(matchingPersonalWorkspacesWhere)
.orderBy(workspace.name, workspace.id)
.limit(limit)
.offset(offset),
db
.select({
id: workspace.id,
name: workspace.name,
archivedAt: workspace.archivedAt,
total: sql<number>`count(*) over()`.mapWith(Number),
})
.from(workspace)
.where(allPersonalWorkspacesWhere)
.orderBy(workspace.id)
.limit(MAX_ADMIN_MEMBER_WORKSPACE_SELECTION + 1),
])
if (!destination) throw new Error('Destination organization not found')
if (!target) throw new Error('User not found')
@@ -1621,6 +1761,9 @@ export async function getDashboardMemberTransferPreflight(
: credentialDependencies.length > 0
? 'Reconnect or remove source-organization credentials owned by this user before transfer'
: null
const matchingWorkspaceTotal = personalWorkspaceCount[0]?.value ?? 0
const totalEligibleWorkspaces = selectionRows[0]?.total ?? 0
const includesAllEligible = totalEligibleWorkspaces <= MAX_ADMIN_MEMBER_WORKSPACE_SELECTION
return {
user: { id: target.id, name: target.name, email: target.email },
@@ -1633,176 +1776,30 @@ export async function getDashboardMemberTransferPreflight(
name: row.name,
archived: row.archivedAt !== null,
})),
workspacePagination: {
total: matchingWorkspaceTotal,
limit,
offset,
hasMore: offset + personalWorkspaces.length < matchingWorkspaceTotal,
},
workspaceSelection: {
totalEligible: totalEligibleWorkspaces,
defaultSelectedIds: includesAllEligible ? selectionRows.map((row) => row.id) : [],
defaultSelectedWorkspaces: includesAllEligible
? selectionRows.map(({ total: _total, archivedAt, ...row }) => ({
...row,
archived: archivedAt !== null,
}))
: [],
includesAllEligible,
limit: MAX_ADMIN_MEMBER_WORKSPACE_SELECTION,
},
credentialDependencies,
canAdd: reason === null,
reason,
}
}
export async function addDashboardOrganizationMember(
organizationId: string,
values: {
userId: string
role: 'admin' | 'member'
usageLimitDollars?: number | null
personalWorkspaceIds?: string[]
},
actor: AdminMutationActor
) {
const selectedWorkspaceIds = [...new Set(values.personalWorkspaceIds ?? [])]
if (selectedWorkspaceIds.length > 0) {
const selectable = await db
.select({ id: workspace.id })
.from(workspace)
.where(
and(
ownedAttachableWorkspacesWhere({ userId: values.userId, includeArchived: true }),
inArray(workspace.id, selectedWorkspaceIds)
)
)
if (selectable.length !== selectedWorkspaceIds.length) {
throw new Error('One or more selected personal workspaces can no longer be moved')
}
}
const [existingMembership] = await db
.select({ id: member.id, organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, values.userId))
.limit(1)
let memberId: string
let transferredFromOrganizationId: string | null = null
if (existingMembership && existingMembership.organizationId !== organizationId) {
const transferred = await transferUserBetweenOrganizations({
userId: values.userId,
sourceOrganizationId: existingMembership.organizationId,
destinationOrganizationId: organizationId,
role: values.role,
usageLimitDollars: values.usageLimitDollars,
setBy: actor.id ?? undefined,
})
if (!transferred.success || !transferred.memberId) {
throw new Error(transferred.error ?? 'Failed to transfer organization member')
}
memberId = transferred.memberId
transferredFromOrganizationId = existingMembership.organizationId
} else {
memberId = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [organizationSubscription] = await tx
.select({ plan: subscription.plan })
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.orderBy(desc(subscription.periodStart))
.limit(1)
const membershipResult = await ensureUserInOrganizationTx(tx, {
userId: values.userId,
organizationId,
role: values.role,
skipSeatValidation: organizationSubscription?.plan.startsWith('team') ?? false,
})
if (
!membershipResult.success ||
!membershipResult.memberId ||
membershipResult.alreadyMember
) {
throw new Error(
membershipResult.alreadyMember
? 'User is already a member'
: (membershipResult.error ?? 'Failed to add member')
)
}
if (values.usageLimitDollars !== undefined) {
await setOrgMemberUsageLimit(
organizationId,
values.userId,
values.usageLimitDollars,
actor.id ?? undefined,
tx
)
}
return membershipResult.memberId
})
}
for (const targetOrganizationId of [transferredFromOrganizationId, organizationId]) {
if (!targetOrganizationId) continue
try {
await reconcileOrganizationSeats({
organizationId: targetOrganizationId,
reason:
targetOrganizationId === organizationId
? 'admin-member-added'
: 'admin-member-transferred-out',
actorId: actor.id ?? undefined,
})
} catch {
// Membership is canonical; Team seat reconciliation is retry-safe.
}
}
try {
await syncUsageLimitsFromSubscription(values.userId)
} catch {
// Membership remains canonical; the next billing reconciliation self-heals the derived limit.
}
const workspaceMoves: Array<{ workspaceId: string; success: boolean; error?: string }> = []
for (const workspaceId of selectedWorkspaceIds) {
try {
await moveWorkspaceToOrganization({
workspaceId,
destinationOrganizationId: organizationId,
adminEmail: actor.email ?? 'admin-api',
expectedOwnerId: values.userId,
})
workspaceMoves.push({ workspaceId, success: true })
} catch (error) {
workspaceMoves.push({
workspaceId,
success: false,
error: getErrorMessage(error, 'Workspace move failed'),
})
}
}
if (transferredFromOrganizationId) {
recordAudit({
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
action: AuditAction.ORG_MEMBER_REMOVED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: transferredFromOrganizationId,
description: 'Admin transferred organization member out',
metadata: { targetUserId: values.userId, destinationOrganizationId: organizationId },
})
}
recordAudit({
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
action: AuditAction.ORG_MEMBER_ADDED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: transferredFromOrganizationId
? `Admin transferred organization member as ${values.role}`
: `Admin added organization member as ${values.role}`,
metadata: {
targetUserId: values.userId,
memberId,
transferredFromOrganizationId,
workspaceMoves,
},
})
return { memberId, transferredFromOrganizationId, workspaceMoves }
}
export async function updateDashboardOrganizationMember(
organizationId: string,
memberId: string,
@@ -0,0 +1,135 @@
/**
* @vitest-environment node
*/
import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
enqueue: vi.fn(),
enqueueMany: vi.fn(),
recordAuditOnce: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
AuditAction: { ORGANIZATION_UPDATED: 'organization.updated' },
AuditResourceType: { ORGANIZATION: 'organization' },
recordAuditOnce: mocks.recordAuditOnce,
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
acquireOrganizationMutationLock: vi.fn(),
}))
vi.mock('@/lib/core/outbox/service', () => ({
deferOutboxHandler: (reason: string, minimumBackoffMs?: number, consumeAttempt = true) => ({
outcome: 'deferred',
reason,
...(minimumBackoffMs === undefined ? {} : { minimumBackoffMs }),
...(consumeAttempt ? {} : { consumeAttempt: false }),
}),
enqueueOutboxEvent: mocks.enqueue,
enqueueOutboxEvents: mocks.enqueueMany,
}))
import {
ADMIN_INVITATION_OPERATION_EVENT_TYPE,
adminInvitationOperationOutboxHandlers,
createAdminInvitationOperation,
} from '@/lib/admin/invitation-operation'
describe('Admin invitation operation', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('atomically accepts one durable child per normalized recipient', async () => {
const now = new Date('2026-08-20T00:00:00.000Z')
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.member, [{ id: 'owner-1' }])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
queueTableRows(schemaMock.outboxEvent, [
{
id: '11111111-1111-4111-8111-111111111111',
eventType: ADMIN_INVITATION_OPERATION_EVENT_TYPE,
status: 'pending',
payload: {
request: {
organizationId: 'org-1',
ownerUserId: 'owner-1',
emails: ['a@example.com', 'b@example.com'],
workspaceIds: ['workspace-1'],
role: 'member',
permission: 'write',
actor: { id: 'admin-1', name: 'Admin', email: 'admin@example.com' },
},
},
attempts: 0,
maxAttempts: 10,
availableAt: now,
lockedAt: null,
lastError: null,
createdAt: now,
processedAt: null,
},
])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [{ selected: 0, completed: 0, failed: 0 }])
const operation = await createAdminInvitationOperation({
operationId: '11111111-1111-4111-8111-111111111111',
organizationId: 'org-1',
emails: ['B@example.com', 'a@example.com'],
workspaceIds: ['workspace-1'],
role: 'member',
permission: 'write',
actor: { id: 'admin-1', name: 'Admin', email: 'admin@example.com' },
})
expect(operation).toMatchObject({
status: 'pending',
invitations: { selected: 2, completed: 0, pending: 2, failedCount: 0 },
})
expect(mocks.enqueueMany).toHaveBeenCalledWith(expect.anything(), 'enterprise.invite-people', [
expect.objectContaining({ email: 'a@example.com', source: 'admin', sequence: 0 }),
expect.objectContaining({ email: 'b@example.com', source: 'admin', sequence: 1 }),
])
expect(mocks.recordAuditOnce).not.toHaveBeenCalled()
})
it('waits for every recipient before the parent operation completes', async () => {
queueTableRows(schemaMock.outboxEvent, [{ selected: 2, active: 1 }])
await expect(
adminInvitationOperationOutboxHandlers[ADMIN_INVITATION_OPERATION_EVENT_TYPE](
{
request: {
organizationId: 'org-1',
ownerUserId: 'owner-1',
emails: ['a@example.com', 'b@example.com'],
workspaceIds: ['workspace-1'],
role: 'member',
permission: 'write',
actor: { id: 'admin-1', name: 'Admin', email: 'admin@example.com' },
},
},
{
eventId: '11111111-1111-4111-8111-111111111111',
eventType: ADMIN_INVITATION_OPERATION_EVENT_TYPE,
attempts: 0,
maxAttempts: 10,
signal: new AbortController().signal,
checkpointPayload: vi.fn(),
}
)
).resolves.toEqual({
outcome: 'deferred',
reason: 'Waiting for invitation recipients',
consumeAttempt: false,
})
expect(mocks.recordAuditOnce).toHaveBeenCalledWith(
'11111111-1111-4111-8111-111111111111:requested',
expect.objectContaining({ resourceId: 'org-1' })
)
})
})
+485
View File
@@ -0,0 +1,485 @@
import { AuditAction, AuditResourceType, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import { member, outboxEvent, user, workspace } from '@sim/db/schema'
import { normalizeEmail } from '@sim/utils/string'
import { and, count, eq, inArray, sql } from 'drizzle-orm'
import { z } from 'zod'
import {
ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE,
enterpriseInvitePeoplePayloadSchema,
} from '@/lib/billing/enterprise-outbox'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import {
deferOutboxHandler,
enqueueOutboxEvent,
enqueueOutboxEvents,
type OutboxHandler,
} from '@/lib/core/outbox/service'
import {
DIRECT_GRANT_EMAIL_EVENT_TYPE,
type DirectGrantEmailPayload,
} from '@/lib/invitations/direct-grant'
import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits'
export const ADMIN_INVITATION_OPERATION_EVENT_TYPE = 'admin.organization-invitation-operation'
const MAX_INVITATION_OPERATION_FAILURE_DETAILS = 100
const adminInvitationOperationRequestSchema = z.object({
organizationId: z.string().min(1),
ownerUserId: z.string().min(1),
emails: z.array(z.string().email()).min(1).max(MAX_INVITE_EMAILS),
workspaceIds: z.array(z.string().min(1)).min(1).max(MAX_INVITE_WORKSPACES),
role: z.enum(['admin', 'member']),
permission: z.enum(['admin', 'write', 'read']),
actor: z.object({
id: z.string().min(1).nullable(),
name: z.string().min(1),
email: z.string().email().nullable(),
}),
})
const adminInvitationOperationPayloadSchema = z.object({
request: adminInvitationOperationRequestSchema,
})
type AdminInvitationOperationPayload = z.infer<typeof adminInvitationOperationPayloadSchema>
export interface AdminInvitationOperationView {
id: string
organizationId: string
status: 'pending' | 'processing' | 'dead_letter' | 'applied'
error: string | null
createdAt: string
invitations: {
selected: number
completed: number
pending: number
failedCount: number
sent: string[]
added: string[]
unchanged: string[]
failed: Array<{ eventId: string; email: string; error: string | null }>
}
notifications: {
selected: number
completed: number
pending: number
failedCount: number
failed: Array<{ eventId: string; email: string; workspaceId: string; error: string | null }>
}
}
export function parseAdminInvitationOperationPayload(
payload: unknown
): AdminInvitationOperationPayload | null {
const parsed = adminInvitationOperationPayloadSchema.safeParse(payload)
return parsed.success ? parsed.data : null
}
function sameRequest(
existing: AdminInvitationOperationPayload['request'],
requested: AdminInvitationOperationPayload['request']
): boolean {
return (
existing.organizationId === requested.organizationId &&
existing.ownerUserId === requested.ownerUserId &&
existing.role === requested.role &&
existing.permission === requested.permission &&
JSON.stringify(existing.emails) === JSON.stringify(requested.emails) &&
JSON.stringify(existing.workspaceIds) === JSON.stringify(requested.workspaceIds)
)
}
function operationStatus(status: string): AdminInvitationOperationView['status'] {
if (status === 'completed') return 'applied'
if (status === 'dead_letter') return 'dead_letter'
return status === 'processing' ? 'processing' : 'pending'
}
async function buildAdminInvitationOperationView(
row: typeof outboxEvent.$inferSelect
): Promise<AdminInvitationOperationView> {
const payload = parseAdminInvitationOperationPayload(row.payload)
if (!payload) throw new Error('Invitation operation payload is invalid')
const operationIdExpression = sql<string>`${outboxEvent.payload} ->> 'provisioningOperationId'`
const invitationRows = await db
.select({
id: outboxEvent.id,
status: outboxEvent.status,
payload: outboxEvent.payload,
error: outboxEvent.lastError,
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE),
eq(operationIdExpression, row.id)
)
)
.orderBy(outboxEvent.createdAt, outboxEvent.id)
.limit(MAX_INVITE_EMAILS)
const notificationOperationIdExpression = sql<string>`${outboxEvent.payload} ->> 'sourceOperationId'`
const [notificationTotals] = await db
.select({
selected: count(),
completed: sql<number>`count(*) filter (where ${outboxEvent.status} = 'completed')`.mapWith(
Number
),
failed: sql<number>`count(*) filter (where ${outboxEvent.status} = 'dead_letter')`.mapWith(
Number
),
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, DIRECT_GRANT_EMAIL_EVENT_TYPE),
eq(notificationOperationIdExpression, row.id)
)
)
const notificationFailureRows =
(notificationTotals?.failed ?? 0) > 0
? await db
.select({
id: outboxEvent.id,
payload: outboxEvent.payload,
error: outboxEvent.lastError,
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, DIRECT_GRANT_EMAIL_EVENT_TYPE),
eq(notificationOperationIdExpression, row.id),
eq(outboxEvent.status, 'dead_letter')
)
)
.orderBy(outboxEvent.createdAt, outboxEvent.id)
.limit(MAX_INVITATION_OPERATION_FAILURE_DETAILS)
: []
const sent: string[] = []
const added: string[] = []
const unchanged: string[] = []
const failed: AdminInvitationOperationView['invitations']['failed'] = []
let invitationCompleted = 0
let invitationFailed = 0
for (const invitationRow of invitationRows) {
const child = enterpriseInvitePeoplePayloadSchema.safeParse(invitationRow.payload)
if (!child.success) continue
if (invitationRow.status === 'completed') {
invitationCompleted += 1
const outcome = child.data.delivery?.outcome ?? 'sent'
if (outcome === 'sent') sent.push(child.data.email)
else if (outcome === 'added') added.push(child.data.email)
else unchanged.push(child.data.email)
} else if (invitationRow.status === 'dead_letter') {
invitationFailed += 1
failed.push({
eventId: invitationRow.id,
email: child.data.email,
error: invitationRow.error,
})
}
}
const notificationSelected = notificationTotals?.selected ?? 0
const notificationCompleted = notificationTotals?.completed ?? 0
const notificationFailed = notificationTotals?.failed ?? 0
return {
id: row.id,
organizationId: payload.request.organizationId,
status: operationStatus(row.status),
error: row.lastError,
createdAt: row.createdAt.toISOString(),
invitations: {
selected: payload.request.emails.length,
completed: invitationCompleted,
pending: Math.max(0, payload.request.emails.length - invitationCompleted - invitationFailed),
failedCount: invitationFailed,
sent,
added,
unchanged,
failed,
},
notifications: {
selected: notificationSelected,
completed: notificationCompleted,
pending: Math.max(0, notificationSelected - notificationCompleted - notificationFailed),
failedCount: notificationFailed,
failed: notificationFailureRows.flatMap((failure) => {
const notification = failure.payload as Partial<DirectGrantEmailPayload>
return typeof notification.email === 'string' &&
typeof notification.workspaceId === 'string'
? [
{
eventId: failure.id,
email: notification.email,
workspaceId: notification.workspaceId,
error: failure.error,
},
]
: []
}),
},
}
}
export async function createAdminInvitationOperation(input: {
operationId: string
organizationId: string
emails: string[]
workspaceIds: string[]
role: 'admin' | 'member'
permission: 'admin' | 'write' | 'read'
actor: { id: string | null; name: string; email: string | null }
}): Promise<AdminInvitationOperationView> {
const emails = input.emails.map(normalizeEmail).sort()
if (new Set(emails).size !== emails.length) {
throw new Error('Each invitation email must be unique')
}
const workspaceIds = [...new Set(input.workspaceIds)].sort()
const row = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, input.organizationId)
const [existing] = await tx
.select()
.from(outboxEvent)
.where(eq(outboxEvent.id, input.operationId))
.for('update')
.limit(1)
if (existing) {
const payload = parseAdminInvitationOperationPayload(existing.payload)
if (!payload || existing.eventType !== ADMIN_INVITATION_OPERATION_EVENT_TYPE) {
throw new Error('Operation ID is already used by another request')
}
const requested = {
...payload.request,
organizationId: input.organizationId,
emails,
workspaceIds,
role: input.role,
permission: input.permission,
actor: input.actor,
}
if (!sameRequest(payload.request, requested)) {
throw new Error('Operation ID was already used with different invitation parameters')
}
if (existing.status === 'dead_letter') {
const [requeued] = await tx
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(eq(outboxEvent.id, input.operationId))
.returning()
if (!requeued) throw new Error('Invitation operation could not be requeued')
return requeued
}
return existing
}
const [owner] = await tx
.select({ id: user.id })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.where(and(eq(member.organizationId, input.organizationId), eq(member.role, 'owner')))
.limit(1)
if (!owner) throw new Error('Organization owner not found')
const selected = await tx
.select({ id: workspace.id })
.from(workspace)
.where(
and(eq(workspace.organizationId, input.organizationId), inArray(workspace.id, workspaceIds))
)
if (selected.length !== workspaceIds.length) {
throw new Error('Every selected workspace must belong to this organization')
}
const request: AdminInvitationOperationPayload['request'] = {
organizationId: input.organizationId,
ownerUserId: owner.id,
emails,
workspaceIds,
role: input.role,
permission: input.permission,
actor: input.actor,
}
await enqueueOutboxEvent(
tx,
ADMIN_INVITATION_OPERATION_EVENT_TYPE,
{ request },
{ id: input.operationId }
)
await enqueueOutboxEvents(
tx,
ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE,
emails.map((email, sequence) => ({
source: 'admin' as const,
provisioningOperationId: input.operationId,
organizationId: input.organizationId,
ownerUserId: owner.id,
email,
role: input.role,
permission: input.permission,
sequence,
}))
)
const [created] = await tx
.select()
.from(outboxEvent)
.where(eq(outboxEvent.id, input.operationId))
.limit(1)
if (!created) throw new Error('Invitation operation was not created')
return created
})
return buildAdminInvitationOperationView(row)
}
export async function getAdminInvitationOperation(
organizationId: string,
operationId: string
): Promise<AdminInvitationOperationView> {
const [row] = await db
.select()
.from(outboxEvent)
.where(
and(
eq(outboxEvent.id, operationId),
eq(outboxEvent.eventType, ADMIN_INVITATION_OPERATION_EVENT_TYPE),
sql`${outboxEvent.payload} #>> '{request,organizationId}' = ${organizationId}`
)
)
.limit(1)
if (!row) throw new Error('Invitation operation not found')
return buildAdminInvitationOperationView(row)
}
export async function retryAdminInvitationOperationJob(
organizationId: string,
operationId: string,
jobId: string
): Promise<AdminInvitationOperationView> {
await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [operation] = await tx
.select({ payload: outboxEvent.payload })
.from(outboxEvent)
.where(
and(
eq(outboxEvent.id, operationId),
eq(outboxEvent.eventType, ADMIN_INVITATION_OPERATION_EVENT_TYPE)
)
)
.limit(1)
const parent = parseAdminInvitationOperationPayload(operation?.payload)
if (!parent || parent.request.organizationId !== organizationId) {
throw new Error('Invitation operation not found')
}
const [job] = await tx
.select({
eventType: outboxEvent.eventType,
status: outboxEvent.status,
payload: outboxEvent.payload,
})
.from(outboxEvent)
.where(eq(outboxEvent.id, jobId))
.for('update')
.limit(1)
const operationKey =
job?.eventType === ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE
? (job.payload as { provisioningOperationId?: unknown }).provisioningOperationId
: (job?.payload as { sourceOperationId?: unknown } | undefined)?.sourceOperationId
if (
!job ||
![ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE, DIRECT_GRANT_EMAIL_EVENT_TYPE].includes(
job.eventType
) ||
operationKey !== operationId
) {
throw new Error('Invitation operation job not found')
}
if (job.status !== 'dead_letter') return
await tx
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(eq(outboxEvent.id, jobId))
})
return getAdminInvitationOperation(organizationId, operationId)
}
const processAdminInvitationOperation: OutboxHandler<unknown> = async (rawPayload, context) => {
const payload = parseAdminInvitationOperationPayload(rawPayload)
if (!payload) throw new Error('Invalid Admin invitation-operation payload')
await recordAuditOnce(`${context.eventId}:requested`, {
actorId: payload.request.actor.id,
actorName: payload.request.actor.name,
actorEmail: payload.request.actor.email,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: payload.request.organizationId,
description: 'Admin requested a durable organization invitation batch',
metadata: {
invitationOperationId: context.eventId,
recipientCount: payload.request.emails.length,
workspaceCount: payload.request.workspaceIds.length,
role: payload.request.role,
permission: payload.request.permission,
},
})
const operationIdExpression = sql<string>`${outboxEvent.payload} ->> 'provisioningOperationId'`
const [invitationTotals] = await db
.select({
selected: count(),
active:
sql<number>`count(*) filter (where ${outboxEvent.status} in ('pending', 'processing'))`.mapWith(
Number
),
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE),
eq(operationIdExpression, context.eventId)
)
)
if ((invitationTotals?.selected ?? 0) !== payload.request.emails.length) {
throw new Error('Invitation operation child set is incomplete')
}
if ((invitationTotals?.active ?? 0) > 0) {
return deferOutboxHandler('Waiting for invitation recipients', undefined, false)
}
const notificationOperationIdExpression = sql<string>`${outboxEvent.payload} ->> 'sourceOperationId'`
const [notificationTotals] = await db
.select({
active:
sql<number>`count(*) filter (where ${outboxEvent.status} in ('pending', 'processing'))`.mapWith(
Number
),
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, DIRECT_GRANT_EMAIL_EVENT_TYPE),
eq(notificationOperationIdExpression, context.eventId)
)
)
if ((notificationTotals?.active ?? 0) > 0) {
return deferOutboxHandler('Waiting for direct-grant notifications', undefined, false)
}
}
export const adminInvitationOperationOutboxHandlers = {
[ADMIN_INVITATION_OPERATION_EVENT_TYPE]: processAdminInvitationOperation,
} as const
+395
View File
@@ -0,0 +1,395 @@
/**
* @vitest-environment node
*/
import { member, organization, outboxEvent, user } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
acquireOrganizationLock: vi.fn(),
acquireUserLock: vi.fn(),
ensureMembership: vi.fn(),
transferMembership: vi.fn(),
setMemberLimit: vi.fn(),
reconcileSeats: vi.fn(),
syncUsageLimits: vi.fn(),
moveWorkspace: vi.fn(),
recordAuditOnce: vi.fn(),
enqueue: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
ORG_MEMBER_ADDED: 'organization.member_added',
ORG_MEMBER_REMOVED: 'organization.member_removed',
},
AuditResourceType: { ORGANIZATION: 'organization' },
recordAuditOnce: mocks.recordAuditOnce,
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
acquireOrganizationMutationLock: mocks.acquireOrganizationLock,
ensureUserInOrganizationTx: mocks.ensureMembership,
transferUserBetweenOrganizations: mocks.transferMembership,
}))
vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({
acquireUserBillingIdentityLock: mocks.acquireUserLock,
}))
vi.mock('@/lib/billing/organizations/member-limits', () => ({
setOrgMemberUsageLimit: mocks.setMemberLimit,
}))
vi.mock('@/lib/billing/organizations/seats', () => ({
reconcileOrganizationSeats: mocks.reconcileSeats,
}))
vi.mock('@/lib/billing/core/usage', () => ({
syncUsageLimitsFromSubscription: mocks.syncUsageLimits,
}))
vi.mock('@/lib/workspaces/admin-move', () => ({
MIGRATED_INVITATION_EMAIL_EVENT_TYPE: 'invitation.send-migrated-link',
moveWorkspaceToOrganization: mocks.moveWorkspace,
}))
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
ownedAttachableWorkspacesWhere: vi.fn(() => undefined),
}))
vi.mock('@/lib/core/outbox/service', () => ({
deferOutboxHandler: (reason: string, _minimum?: number, consumeAttempt = true) => ({
outcome: 'deferred',
reason,
...(consumeAttempt ? {} : { consumeAttempt: false }),
}),
enqueueOutboxEvent: mocks.enqueue,
outboxEventHasSourceOperationId: vi.fn(() => undefined),
outboxPayloadHasSourceOperationId: vi.fn(
(payload: { sourceOperationId?: string; sourceOperationIds?: string[] }, operationId: string) =>
payload.sourceOperationId === operationId || payload.sourceOperationIds?.includes(operationId)
),
}))
import {
getAdminMemberOperation,
processAdminMemberOperation,
startAdminMemberOperation,
} from '@/lib/admin/member-operation'
const actor = { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
function payload(workspaceIds: string[]) {
return {
request: {
organizationId: 'org-new',
userId: 'user-1',
role: 'member' as const,
workspaceIds,
sourceOrganizationId: 'org-old',
actor,
},
progress: {
memberId: null,
transferredFromOrganizationId: null,
nextWorkspaceIndex: 0,
currentWorkspaceId: null,
},
}
}
afterAll(resetDbChainMock)
describe('durable admin member operation', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mocks.reconcileSeats.mockResolvedValue(undefined)
mocks.syncUsageLimits.mockResolvedValue(undefined)
mocks.moveWorkspace.mockResolvedValue({})
mocks.recordAuditOnce.mockResolvedValue(undefined)
})
it('recovers the same operation after membership committed but its response was lost', async () => {
const existingPayload = payload(['workspace-1'])
queueTableRows(outboxEvent, [
{
id: '1c38ca61-79d5-4d24-8094-c29cb52132ba',
eventType: 'admin.organization-member-operation',
payload: existingPayload,
status: 'pending',
attempts: 0,
maxAttempts: 10,
availableAt: new Date('2026-08-20T00:00:00.000Z'),
lockedAt: null,
lastError: null,
createdAt: new Date('2026-08-20T00:00:00.000Z'),
processedAt: null,
},
])
queueTableRows(organization, [{ id: 'org-new' }])
queueTableRows(user, [
{
id: 'user-1',
memberId: 'member-new',
role: 'member',
organizationId: 'org-new',
},
])
await expect(
startAdminMemberOperation(
'1c38ca61-79d5-4d24-8094-c29cb52132ba',
'org-new',
{
userId: 'user-1',
role: 'member',
personalWorkspaceIds: ['workspace-1'],
},
actor
)
).resolves.toMatchObject({
status: 'pending',
workspaceMoves: { selected: 1, moved: 0, pending: 1 },
})
expect(mocks.enqueue).not.toHaveBeenCalled()
})
it('infers committed membership, restores deterministic audits, and resumes workspace moves', async () => {
queueTableRows(member, [{ id: 'member-new', role: 'member', organizationId: 'org-new' }])
const checkpointPayload = vi.fn()
await expect(
processAdminMemberOperation(payload(['workspace-1', 'workspace-2']), {
eventId: 'operation-1',
eventType: 'admin.organization-member-operation',
attempts: 1,
checkpointPayload,
})
).resolves.toBeUndefined()
expect(mocks.recordAuditOnce).toHaveBeenCalledWith(
'operation-1:member-added',
expect.objectContaining({ resourceId: 'org-new' })
)
expect(mocks.moveWorkspace).toHaveBeenNthCalledWith(1, {
workspaceId: 'workspace-1',
destinationOrganizationId: 'org-new',
adminEmail: 'admin@sim.ai',
auditActor: { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' },
auditOperationId: 'operation-1',
expectedOwnerId: 'user-1',
operationCorrelationId: 'operation-1',
})
expect(mocks.moveWorkspace).toHaveBeenNthCalledWith(2, {
workspaceId: 'workspace-2',
destinationOrganizationId: 'org-new',
adminEmail: 'admin@sim.ai',
auditActor: { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' },
auditOperationId: 'operation-1',
expectedOwnerId: 'user-1',
operationCorrelationId: 'operation-1',
})
expect(checkpointPayload).toHaveBeenLastCalledWith({
progress: {
memberId: 'member-new',
transferredFromOrganizationId: 'org-old',
nextWorkspaceIndex: 2,
currentWorkspaceId: null,
},
})
})
it('applies the requested role when a concurrent join wins the membership insert', async () => {
const concurrentPayload = {
...payload([]),
request: {
...payload([]).request,
role: 'admin' as const,
sourceOrganizationId: null,
},
}
queueTableRows(member, [])
queueTableRows(member, [{ role: 'member' }])
mocks.ensureMembership.mockResolvedValue({
success: true,
memberId: 'member-new',
alreadyMember: true,
})
const checkpointPayload = vi.fn()
await expect(
processAdminMemberOperation(concurrentPayload, {
eventId: 'operation-1',
eventType: 'admin.organization-member-operation',
attempts: 0,
checkpointPayload,
})
).resolves.toBeUndefined()
expect(dbChainMockFns.set).toHaveBeenCalledWith({ role: 'admin' })
expect(checkpointPayload).toHaveBeenCalledWith({
progress: {
memberId: 'member-new',
transferredFromOrganizationId: null,
nextWorkspaceIndex: 0,
currentWorkspaceId: null,
},
})
})
it('checkpoints a bounded workspace batch and defers without consuming an attempt', async () => {
queueTableRows(member, [{ id: 'member-new', role: 'member', organizationId: 'org-new' }])
const checkpointPayload = vi.fn()
const workspaceIds = Array.from({ length: 12 }, (_, index) => `workspace-${index + 1}`)
await expect(
processAdminMemberOperation(payload(workspaceIds), {
eventId: 'operation-1',
eventType: 'admin.organization-member-operation',
attempts: 0,
checkpointPayload,
})
).resolves.toEqual({
outcome: 'deferred',
reason: 'Continuing bounded member workspace moves',
consumeAttempt: false,
})
expect(mocks.moveWorkspace).toHaveBeenCalledTimes(10)
expect(checkpointPayload).toHaveBeenLastCalledWith({
progress: {
memberId: 'member-new',
transferredFromOrganizationId: 'org-old',
nextWorkspaceIndex: 10,
currentWorkspaceId: null,
},
})
})
it('checkpoints the active workspace before attempting its move', async () => {
queueTableRows(member, [{ id: 'member-new', role: 'member', organizationId: 'org-new' }])
mocks.moveWorkspace.mockRejectedValueOnce(new Error('Move failed'))
const checkpointPayload = vi.fn()
await expect(
processAdminMemberOperation(payload(['workspace-1']), {
eventId: 'operation-1',
eventType: 'admin.organization-member-operation',
attempts: 0,
checkpointPayload,
})
).rejects.toThrow('Move failed')
expect(checkpointPayload).toHaveBeenLastCalledWith({
progress: {
memberId: 'member-new',
transferredFromOrganizationId: 'org-old',
nextWorkspaceIndex: 0,
currentWorkspaceId: 'workspace-1',
},
})
})
it('does not mislabel a membership failure as a workspace failure', async () => {
queueTableRows(outboxEvent, [
{
id: '1c38ca61-79d5-4d24-8094-c29cb52132ba',
eventType: 'admin.organization-member-operation',
payload: payload(['workspace-1', 'workspace-2']),
status: 'dead_letter',
lastError: 'Seat limit reached',
createdAt: new Date('2026-08-20T00:00:00.000Z'),
},
])
queueTableRows(outboxEvent, [{ selected: 0, completed: 0, failed: 0 }])
await expect(
getAdminMemberOperation('org-new', '1c38ca61-79d5-4d24-8094-c29cb52132ba')
).resolves.toMatchObject({
error: 'Seat limit reached',
workspaceMoves: {
selected: 2,
moved: 0,
pending: 2,
failedCount: 0,
failed: [],
},
})
})
it('separates the active failed workspace from still-pending workspaces', async () => {
queueTableRows(outboxEvent, [
{
id: '1c38ca61-79d5-4d24-8094-c29cb52132ba',
eventType: 'admin.organization-member-operation',
payload: {
...payload(['workspace-1', 'workspace-2', 'workspace-3']),
progress: {
memberId: 'member-new',
transferredFromOrganizationId: 'org-old',
nextWorkspaceIndex: 1,
currentWorkspaceId: 'workspace-2',
},
},
status: 'dead_letter',
lastError: 'Move failed',
createdAt: new Date('2026-08-20T00:00:00.000Z'),
},
])
queueTableRows(outboxEvent, [{ selected: 0, completed: 0, failed: 0 }])
await expect(
getAdminMemberOperation('org-new', '1c38ca61-79d5-4d24-8094-c29cb52132ba')
).resolves.toMatchObject({
workspaceMoves: {
selected: 3,
moved: 1,
pending: 1,
failedCount: 1,
failed: [{ workspaceId: 'workspace-2', error: 'Move failed' }],
},
})
})
it('keeps migrated invitation delivery visible after the parent operation is applied', async () => {
queueTableRows(outboxEvent, [
{
id: '1c38ca61-79d5-4d24-8094-c29cb52132ba',
eventType: 'admin.organization-member-operation',
payload: {
...payload(['workspace-1']),
progress: {
memberId: 'member-new',
transferredFromOrganizationId: 'org-old',
nextWorkspaceIndex: 1,
currentWorkspaceId: null,
},
},
status: 'completed',
lastError: null,
createdAt: new Date('2026-08-20T00:00:00.000Z'),
},
])
queueTableRows(outboxEvent, [{ selected: 2, completed: 1, failed: 1 }])
queueTableRows(outboxEvent, [
{
eventId: 'email-job-2',
invitationId: 'invitation-2',
error: 'provider unavailable',
},
])
await expect(
getAdminMemberOperation('org-new', '1c38ca61-79d5-4d24-8094-c29cb52132ba')
).resolves.toMatchObject({
status: 'applied',
followUpJobs: {
selected: 2,
completed: 1,
pending: 0,
failedCount: 1,
failed: [
{
eventId: 'email-job-2',
kind: 'migrated_invitation_email',
subjectId: 'invitation-2',
error: 'provider unavailable',
},
],
},
})
})
})
+701
View File
@@ -0,0 +1,701 @@
import { AuditAction, AuditResourceType, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, outboxEvent, subscription, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
import { z } from 'zod'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock'
import { setOrgMemberUsageLimit } from '@/lib/billing/organizations/member-limits'
import {
acquireOrganizationMutationLock,
ensureUserInOrganizationTx,
transferUserBetweenOrganizations,
} from '@/lib/billing/organizations/membership'
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import {
deferOutboxHandler,
enqueueOutboxEvent,
type OutboxHandler,
outboxEventHasSourceOperationId,
outboxPayloadHasSourceOperationId,
} from '@/lib/core/outbox/service'
import type { DbOrTx } from '@/lib/db/types'
import {
MIGRATED_INVITATION_EMAIL_EVENT_TYPE,
moveWorkspaceToOrganization,
} from '@/lib/workspaces/admin-move'
import { ownedAttachableWorkspacesWhere } from '@/lib/workspaces/organization-workspaces'
export const ADMIN_MEMBER_OPERATION_EVENT_TYPE = 'admin.organization-member-operation'
const MEMBER_OPERATION_WORKSPACE_BATCH_SIZE = 10
const ADMIN_API_AUDIT_EMAIL = 'admin-api@internal.simstudio.ai'
const logger = createLogger('AdminMemberOperation')
const memberOperationRequestSchema = z
.object({
organizationId: z.string().min(1),
userId: z.string().min(1),
role: z.enum(['admin', 'member']),
usageLimitDollars: z.number().min(0).nullable().optional(),
workspaceIds: z.array(z.string().min(1)).max(1_000),
sourceOrganizationId: z.string().min(1).nullable(),
actor: z.object({
id: z.string().min(1).nullable(),
name: z.string().min(1),
email: z.string().email().nullable(),
}),
})
.strict()
const memberOperationProgressSchema = z
.object({
memberId: z.string().min(1).nullable().default(null),
transferredFromOrganizationId: z.string().min(1).nullable().default(null),
nextWorkspaceIndex: z.number().int().min(0).default(0),
currentWorkspaceId: z.string().min(1).nullable().default(null),
})
.strict()
const memberOperationPayloadSchema = z
.object({
request: memberOperationRequestSchema,
progress: memberOperationProgressSchema.default({
memberId: null,
transferredFromOrganizationId: null,
nextWorkspaceIndex: 0,
currentWorkspaceId: null,
}),
})
.strict()
type MemberOperationPayload = z.infer<typeof memberOperationPayloadSchema>
export interface AdminMemberOperationActor {
id: string | null
name: string
email: string | null
}
export interface AdminMemberOperationView {
id: string
organizationId: string
userId: string
status: 'pending' | 'processing' | 'dead_letter' | 'applied'
memberId: string | null
transferredFromOrganizationId: string | null
error: string | null
createdAt: string
workspaceMoves: {
selected: number
moved: number
pending: number
failedCount: number
failed: Array<{ workspaceId: string; error: string }>
}
followUpJobs: {
selected: number
completed: number
pending: number
failedCount: number
failed: Array<{
eventId: string
kind: 'migrated_invitation_email'
subjectId: string
error: string | null
}>
}
}
function parseMemberOperationPayload(value: unknown): MemberOperationPayload {
const parsed = memberOperationPayloadSchema.safeParse(value)
if (!parsed.success) throw new Error('Admin member operation payload is invalid')
return parsed.data
}
function getMemberFollowUpSubject(eventType: string, payload: unknown): string | null {
if (
eventType !== MIGRATED_INVITATION_EMAIL_EVENT_TYPE ||
!payload ||
typeof payload !== 'object'
) {
return null
}
const invitationId = (payload as Record<string, unknown>).invitationId
return typeof invitationId === 'string' && invitationId.length > 0 ? invitationId : null
}
async function getMemberOperationFollowUpJobs(
operationId: string,
executor: DbOrTx = db
): Promise<AdminMemberOperationView['followUpJobs']> {
const [progress] = await executor
.select({
selected: count(),
completed: sql<number>`count(*) filter (where ${outboxEvent.status} = 'completed')`.mapWith(
Number
),
failed: sql<number>`count(*) filter (where ${outboxEvent.status} = 'dead_letter')`.mapWith(
Number
),
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, MIGRATED_INVITATION_EMAIL_EVENT_TYPE),
outboxEventHasSourceOperationId(operationId)
)
)
const selected = progress?.selected ?? 0
const completed = progress?.completed ?? 0
const failedCount = progress?.failed ?? 0
const failedRows =
failedCount > 0
? await executor
.select({
eventId: outboxEvent.id,
invitationId: sql<string | null>`${outboxEvent.payload} ->> 'invitationId'`,
error: outboxEvent.lastError,
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, MIGRATED_INVITATION_EMAIL_EVENT_TYPE),
eq(outboxEvent.status, 'dead_letter'),
outboxEventHasSourceOperationId(operationId)
)
)
.orderBy(outboxEvent.createdAt, outboxEvent.id)
.limit(100)
: []
return {
selected,
completed,
pending: Math.max(0, selected - completed - failedCount),
failedCount,
failed: failedRows.flatMap((row) =>
row.invitationId
? [
{
eventId: row.eventId,
kind: 'migrated_invitation_email' as const,
subjectId: row.invitationId,
error: row.error,
},
]
: []
),
}
}
async function toMemberOperationView(
row: Pick<
typeof outboxEvent.$inferSelect,
'id' | 'status' | 'lastError' | 'createdAt' | 'payload'
>,
executor: DbOrTx = db
): Promise<AdminMemberOperationView> {
const payload = parseMemberOperationPayload(row.payload)
const followUpJobs = await getMemberOperationFollowUpJobs(row.id, executor)
const failed =
row.status === 'dead_letter' && payload.progress.currentWorkspaceId
? [
{
workspaceId: payload.progress.currentWorkspaceId,
error: row.lastError ?? 'Workspace move failed',
},
]
: []
return {
id: row.id,
organizationId: payload.request.organizationId,
userId: payload.request.userId,
status:
row.status === 'completed' ? 'applied' : (row.status as AdminMemberOperationView['status']),
memberId: payload.progress.memberId,
transferredFromOrganizationId: payload.progress.transferredFromOrganizationId,
error: row.lastError,
createdAt: row.createdAt.toISOString(),
workspaceMoves: {
selected: payload.request.workspaceIds.length,
moved: payload.progress.nextWorkspaceIndex,
pending: Math.max(
0,
payload.request.workspaceIds.length - payload.progress.nextWorkspaceIndex - failed.length
),
failedCount: failed.length,
failed,
},
followUpJobs,
}
}
function sameMemberOperationRequest(
existing: MemberOperationPayload['request'],
requested: Pick<
MemberOperationPayload['request'],
'organizationId' | 'userId' | 'role' | 'usageLimitDollars' | 'workspaceIds'
>
): boolean {
return (
existing.organizationId === requested.organizationId &&
existing.userId === requested.userId &&
existing.role === requested.role &&
existing.usageLimitDollars === requested.usageLimitDollars &&
JSON.stringify(existing.workspaceIds) === JSON.stringify(requested.workspaceIds)
)
}
export async function startAdminMemberOperation(
operationId: string,
organizationId: string,
values: {
userId: string
role: 'admin' | 'member'
usageLimitDollars?: number | null
personalWorkspaceIds?: string[]
},
actor: AdminMemberOperationActor
): Promise<AdminMemberOperationView> {
const workspaceIds = [...new Set(values.personalWorkspaceIds ?? [])].sort()
if (workspaceIds.length > 1_000) throw new Error('At most 1,000 workspaces can be moved')
return db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
await acquireUserBillingIdentityLock(tx, values.userId)
const [existingOperation] = await tx
.select()
.from(outboxEvent)
.where(eq(outboxEvent.id, operationId))
.for('update')
.limit(1)
const [[destination], [target]] = await Promise.all([
tx
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1),
tx
.select({
id: user.id,
memberId: member.id,
role: member.role,
organizationId: member.organizationId,
})
.from(user)
.leftJoin(member, eq(member.userId, user.id))
.where(eq(user.id, values.userId))
.limit(1),
])
if (!destination) throw new Error('Destination organization not found')
if (!target) throw new Error('User not found')
const request: MemberOperationPayload['request'] = {
organizationId,
userId: values.userId,
role: values.role,
...(values.usageLimitDollars !== undefined
? { usageLimitDollars: values.usageLimitDollars }
: {}),
workspaceIds,
sourceOrganizationId:
target.organizationId && target.organizationId !== organizationId
? target.organizationId
: null,
actor: {
id: actor.id,
name: actor.name,
email: actor.email,
},
}
if (existingOperation) {
if (existingOperation.eventType !== ADMIN_MEMBER_OPERATION_EVENT_TYPE) {
throw new Error('Operation ID is already used by another operation')
}
const existingPayload = parseMemberOperationPayload(existingOperation.payload)
if (!sameMemberOperationRequest(existingPayload.request, request)) {
throw new Error('Operation ID is already bound to different member-operation parameters')
}
if (existingOperation.status === 'dead_letter') {
const [requeued] = await tx
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
lockedAt: null,
processedAt: null,
availableAt: new Date(),
})
.where(eq(outboxEvent.id, operationId))
.returning()
return toMemberOperationView(requeued, tx)
}
return toMemberOperationView(existingOperation, tx)
}
if (target.organizationId === organizationId) {
throw new Error('User is already a member of this organization')
}
if (target.role === 'owner') {
throw new Error('Transfer organization ownership before moving this user')
}
if (workspaceIds.length > 0) {
const selectable = await tx
.select({ id: workspace.id })
.from(workspace)
.where(
and(
ownedAttachableWorkspacesWhere({ userId: values.userId, includeArchived: true }),
inArray(workspace.id, workspaceIds)
)
)
if (selectable.length !== workspaceIds.length) {
throw new Error('One or more selected personal workspaces can no longer be moved')
}
}
const [unfinishedOperation] = await tx
.select({ id: outboxEvent.id })
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, ADMIN_MEMBER_OPERATION_EVENT_TYPE),
inArray(outboxEvent.status, ['pending', 'processing', 'dead_letter']),
sql`${outboxEvent.payload} #>> '{request,userId}' = ${values.userId}`,
sql`${outboxEvent.payload} #>> '{request,organizationId}' = ${organizationId}`
)
)
.limit(1)
if (unfinishedOperation) {
throw new Error('This member already has an unfinished add or transfer operation')
}
const payload: MemberOperationPayload = {
request,
progress: {
memberId: null,
transferredFromOrganizationId: null,
nextWorkspaceIndex: 0,
currentWorkspaceId: null,
},
}
await enqueueOutboxEvent(tx, ADMIN_MEMBER_OPERATION_EVENT_TYPE, payload, {
id: operationId,
maxAttempts: 10,
})
const [created] = await tx
.select()
.from(outboxEvent)
.where(eq(outboxEvent.id, operationId))
.limit(1)
if (!created) throw new Error('Member operation was not created')
return toMemberOperationView(created, tx)
})
}
export async function getAdminMemberOperation(
organizationId: string,
operationId: string
): Promise<AdminMemberOperationView> {
const [row] = await db
.select()
.from(outboxEvent)
.where(
and(
eq(outboxEvent.id, operationId),
eq(outboxEvent.eventType, ADMIN_MEMBER_OPERATION_EVENT_TYPE),
sql`${outboxEvent.payload} #>> '{request,organizationId}' = ${organizationId}`
)
)
.limit(1)
if (!row) throw new Error('Member operation not found')
return toMemberOperationView(row)
}
export async function retryAdminMemberFollowUpJob(
organizationId: string,
operationId: string,
jobEventId: string,
actor: AdminMemberOperationActor
): Promise<AdminMemberOperationView> {
const [operationRow] = await db
.select()
.from(outboxEvent)
.where(
and(
eq(outboxEvent.id, operationId),
eq(outboxEvent.eventType, ADMIN_MEMBER_OPERATION_EVENT_TYPE),
sql`${outboxEvent.payload} #>> '{request,organizationId}' = ${organizationId}`
)
)
.limit(1)
if (!operationRow) throw new Error('Member operation not found')
const retried = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [job] = await tx
.select({
eventType: outboxEvent.eventType,
payload: outboxEvent.payload,
status: outboxEvent.status,
})
.from(outboxEvent)
.where(eq(outboxEvent.id, jobEventId))
.for('update')
.limit(1)
if (
!job ||
getMemberFollowUpSubject(job.eventType, job.payload) === null ||
!outboxPayloadHasSourceOperationId(job.payload, operationId)
) {
throw new Error('Member-operation follow-up job not found')
}
if (job.status !== 'dead_letter') return false
await tx
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(eq(outboxEvent.id, jobEventId))
return true
})
if (retried) {
await recordAuditOnce(`${operationId}:follow-up-retry:${jobEventId}`, {
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: 'Admin retried a member-operation invitation email',
metadata: { organizationId, operationId, jobEventId },
})
}
return getAdminMemberOperation(organizationId, operationId)
}
async function applyMembership(
payload: MemberOperationPayload,
context: Parameters<OutboxHandler<unknown>>[1]
): Promise<MemberOperationPayload['progress']> {
if (payload.progress.memberId) return payload.progress
const request = payload.request
const [currentMembership] = await db
.select({ id: member.id, role: member.role, organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, request.userId))
.limit(1)
let memberId: string
if (currentMembership?.organizationId === request.organizationId) {
if (currentMembership.role !== request.role) {
throw new Error('Member role changed while the durable operation was being recovered')
}
memberId = currentMembership.id
} else if (request.sourceOrganizationId) {
if (currentMembership?.organizationId !== request.sourceOrganizationId) {
throw new Error('Member organization changed before the transfer could be applied')
}
const transferred = await transferUserBetweenOrganizations({
userId: request.userId,
sourceOrganizationId: request.sourceOrganizationId,
destinationOrganizationId: request.organizationId,
role: request.role,
usageLimitDollars: request.usageLimitDollars,
setBy: request.actor.id ?? undefined,
})
if (!transferred.success || !transferred.memberId) {
throw new Error(transferred.error ?? 'Failed to transfer organization member')
}
memberId = transferred.memberId
} else {
if (currentMembership) throw new Error('User joined another organization before being added')
memberId = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, request.organizationId)
await acquireUserBillingIdentityLock(tx, request.userId)
const [organizationSubscription] = await tx
.select({ plan: subscription.plan })
.from(subscription)
.where(
and(
eq(subscription.referenceId, request.organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.orderBy(desc(subscription.periodStart))
.limit(1)
const membershipResult = await ensureUserInOrganizationTx(tx, {
userId: request.userId,
organizationId: request.organizationId,
role: request.role,
skipSeatValidation: organizationSubscription?.plan.startsWith('team') ?? false,
})
if (!membershipResult.success || !membershipResult.memberId) {
throw new Error(membershipResult.error ?? 'Failed to add organization member')
}
if (membershipResult.alreadyMember) {
const [concurrentMembership] = await tx
.select({ role: member.role })
.from(member)
.where(eq(member.id, membershipResult.memberId))
.for('update')
.limit(1)
if (!concurrentMembership) {
throw new Error('Concurrent organization membership could not be recovered')
}
if (concurrentMembership.role === 'owner') {
throw new Error('Organization ownership changed while the member was being added')
}
if (concurrentMembership.role !== request.role) {
await tx
.update(member)
.set({ role: request.role })
.where(eq(member.id, membershipResult.memberId))
}
}
if (request.usageLimitDollars !== undefined) {
await setOrgMemberUsageLimit(
request.organizationId,
request.userId,
request.usageLimitDollars,
request.actor.id ?? undefined,
tx
)
}
return membershipResult.memberId
})
}
const progress = {
...payload.progress,
memberId,
transferredFromOrganizationId: request.sourceOrganizationId,
}
await context.checkpointPayload({ progress })
return progress
}
async function ensureMembershipAudits(
operationId: string,
payload: MemberOperationPayload,
progress: MemberOperationPayload['progress']
): Promise<void> {
const request = payload.request
if (request.sourceOrganizationId) {
await recordAuditOnce(`${operationId}:member-removed`, {
actorId: request.actor.id,
actorName: request.actor.name,
actorEmail: request.actor.email,
action: AuditAction.ORG_MEMBER_REMOVED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: request.sourceOrganizationId,
description: 'Admin transferred organization member out',
metadata: {
targetUserId: request.userId,
destinationOrganizationId: request.organizationId,
operationId,
},
})
}
await recordAuditOnce(`${operationId}:member-added`, {
actorId: request.actor.id,
actorName: request.actor.name,
actorEmail: request.actor.email,
action: AuditAction.ORG_MEMBER_ADDED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: request.organizationId,
description: request.sourceOrganizationId
? `Admin transferred organization member as ${request.role}`
: `Admin added organization member as ${request.role}`,
metadata: {
targetUserId: request.userId,
memberId: progress.memberId,
transferredFromOrganizationId: request.sourceOrganizationId,
operationId,
},
})
}
export const processAdminMemberOperation: OutboxHandler<unknown> = async (rawPayload, context) => {
const payload = parseMemberOperationPayload(rawPayload)
const progress = await applyMembership(payload, context)
await ensureMembershipAudits(context.eventId, payload, progress)
for (const organizationId of [requestSource(payload), payload.request.organizationId]) {
if (!organizationId) continue
try {
await reconcileOrganizationSeats({
organizationId,
reason:
organizationId === payload.request.organizationId
? 'admin-member-added'
: 'admin-member-transferred-out',
actorId: payload.request.actor.id ?? undefined,
})
} catch (error) {
logger.warn('Member operation seat reconciliation will self-heal', {
organizationId,
operationId: context.eventId,
error: getErrorMessage(error),
})
}
}
try {
await syncUsageLimitsFromSubscription(payload.request.userId)
} catch (error) {
logger.warn('Member operation usage-limit reconciliation will self-heal', {
userId: payload.request.userId,
operationId: context.eventId,
error: getErrorMessage(error),
})
}
let nextWorkspaceIndex = progress.nextWorkspaceIndex
const batchEnd = Math.min(
payload.request.workspaceIds.length,
nextWorkspaceIndex + MEMBER_OPERATION_WORKSPACE_BATCH_SIZE
)
while (nextWorkspaceIndex < batchEnd) {
const currentWorkspaceId = payload.request.workspaceIds[nextWorkspaceIndex]
await context.checkpointPayload({
progress: { ...progress, nextWorkspaceIndex, currentWorkspaceId },
})
await moveWorkspaceToOrganization({
workspaceId: currentWorkspaceId,
destinationOrganizationId: payload.request.organizationId,
adminEmail: payload.request.actor.email ?? ADMIN_API_AUDIT_EMAIL,
auditActor: payload.request.actor,
auditOperationId: context.eventId,
operationCorrelationId: context.eventId,
expectedOwnerId: payload.request.userId,
})
nextWorkspaceIndex += 1
await context.checkpointPayload({
progress: { ...progress, nextWorkspaceIndex, currentWorkspaceId: null },
})
}
if (nextWorkspaceIndex < payload.request.workspaceIds.length) {
return deferOutboxHandler('Continuing bounded member workspace moves', undefined, false)
}
}
function requestSource(payload: MemberOperationPayload): string | null {
return payload.request.sourceOrganizationId
}
export const adminMemberOperationOutboxHandlers = {
[ADMIN_MEMBER_OPERATION_EVENT_TYPE]: processAdminMemberOperation,
} as const
@@ -0,0 +1,583 @@
/** @vitest-environment node */
import { outboxEvent, subscription } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
vi.unmock('drizzle-orm')
const mocks = vi.hoisted(() => ({
acquireOrganizationMutationLock: vi.fn(),
enqueueOutboxEvent: vi.fn(),
subscriptionCancel: vi.fn(),
invoicesList: vi.fn(),
invoicePaymentsList: vi.fn(),
paymentIntentsRetrieve: vi.fn(),
chargesRetrieve: vi.fn(),
refundsList: vi.fn(),
refundsCreate: vi.fn(),
recordAuditOnce: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
SUBSCRIPTION_CANCELLED: 'subscription.cancelled',
SUBSCRIPTION_REFUNDED: 'subscription.refunded',
},
AuditResourceType: { SUBSCRIPTION: 'subscription' },
recordAudit: vi.fn(),
recordAuditOnce: mocks.recordAuditOnce,
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
acquireOrganizationMutationLock: mocks.acquireOrganizationMutationLock,
}))
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: () => ({
subscriptions: { cancel: mocks.subscriptionCancel },
invoices: { list: mocks.invoicesList },
invoicePayments: { list: mocks.invoicePaymentsList },
paymentIntents: { retrieve: mocks.paymentIntentsRetrieve },
charges: { retrieve: mocks.chargesRetrieve },
refunds: { list: mocks.refundsList, create: mocks.refundsCreate },
}),
}))
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
OUTBOX_EVENT_TYPES: {
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY: 'stripe.cancel-subscription-immediately',
},
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: mocks.enqueueOutboxEvent,
}))
import {
getDashboardSubscriptionBillingActions,
refundDashboardSubscriptionPayment,
requestDashboardSubscriptionCancellation,
} from '@/lib/admin/subscription-lifecycle'
const actor = { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' }
const activeSubscription = {
id: 'sub-row-1',
referenceId: 'org-1',
stripeSubscriptionId: 'sub_stripe_1',
status: 'active',
cancelAtPeriodEnd: false,
periodStart: new Date('2026-01-01T00:00:00.000Z'),
}
afterAll(resetDbChainMock)
describe('admin subscription cancellation', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mocks.enqueueOutboxEvent.mockResolvedValue('outbox-1')
mocks.subscriptionCancel.mockResolvedValue({ id: 'sub_stripe_1', status: 'canceled' })
mocks.invoicesList.mockResolvedValue({ data: [], has_more: false })
mocks.invoicePaymentsList.mockResolvedValue({ data: [], has_more: false })
mocks.refundsList.mockResolvedValue({ data: [], has_more: false })
mocks.refundsCreate.mockResolvedValue({
id: 're_1',
amount: 2500,
status: 'succeeded',
metadata: {},
})
})
it('uses the existing cancel-at-period-end outbox flow', async () => {
queueTableRows(outboxEvent, [])
queueTableRows(subscription, [activeSubscription])
const result = await requestDashboardSubscriptionCancellation({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'period_end',
actor,
})
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
expect(mocks.enqueueOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
'stripe.sync-cancel-at-period-end',
expect.objectContaining({
subscriptionId: 'sub-row-1',
stripeSubscriptionId: 'sub_stripe_1',
})
)
expect(mocks.subscriptionCancel).not.toHaveBeenCalled()
expect(result).toMatchObject({
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
status: 'pending',
})
})
it('durably queues immediate Stripe cancellation and leaves cleanup to the webhook', async () => {
queueTableRows(subscription, [activeSubscription])
queueTableRows(outboxEvent, [])
const result = await requestDashboardSubscriptionCancellation({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'immediate',
actor,
})
expect(mocks.enqueueOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
'stripe.cancel-subscription-immediately',
expect.objectContaining({
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
subscriptionId: 'sub-row-1',
stripeSubscriptionId: 'sub_stripe_1',
})
)
expect(mocks.subscriptionCancel).not.toHaveBeenCalled()
expect(result).toMatchObject({ status: 'pending' })
})
it('requeues the same dead-lettered period-end cancellation operation', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([{ id: activeSubscription.id }])
queueTableRows(outboxEvent, [
{
id: 'outbox-1',
eventType: 'stripe.sync-cancel-at-period-end',
status: 'dead_letter',
subscriptionId: 'sub-row-1',
reason: 'admin-dashboard-cancel-at-period-end',
},
])
const result = await requestDashboardSubscriptionCancellation({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'period_end',
actor,
})
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ status: 'pending', attempts: 0, lastError: null })
)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ cancelAtPeriodEnd: true })
expect(result).toMatchObject({
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
status: 'pending',
})
})
it('replays an immediate cancellation after the webhook removed active entitlement', async () => {
queueTableRows(outboxEvent, [
{
id: 'outbox-1',
eventType: 'stripe.cancel-subscription-immediately',
status: 'completed',
subscriptionId: 'sub-row-1',
reason: 'admin-dashboard-cancel-immediately',
},
])
const result = await requestDashboardSubscriptionCancellation({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'immediate',
actor,
})
expect(result).toMatchObject({ status: 'applied' })
expect(mocks.enqueueOutboxEvent).not.toHaveBeenCalled()
expect(mocks.subscriptionCancel).not.toHaveBeenCalled()
})
it('rejects reuse of a cancellation operation id with different timing', async () => {
queueTableRows(outboxEvent, [
{
id: 'outbox-1',
eventType: 'stripe.sync-cancel-at-period-end',
status: 'completed',
subscriptionId: 'sub-row-1',
reason: 'admin-dashboard-cancel-at-period-end',
},
])
await expect(
requestDashboardSubscriptionCancellation({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'immediate',
actor,
})
).rejects.toThrow('different parameters')
})
it('fails closed instead of guessing when an organization has multiple active subscriptions', async () => {
queueTableRows(subscription, [
activeSubscription,
{ ...activeSubscription, id: 'sub-row-2', stripeSubscriptionId: 'sub_stripe_2' },
])
await expect(
requestDashboardSubscriptionCancellation({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'immediate',
actor,
})
).rejects.toThrow('Multiple active organization subscriptions')
expect(mocks.subscriptionCancel).not.toHaveBeenCalled()
})
})
describe('admin subscription billing actions', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mocks.invoicesList.mockResolvedValue({
data: [{ id: 'in_1', description: 'Annual Enterprise invoice' }],
has_more: false,
})
mocks.invoicePaymentsList.mockResolvedValue({
data: [
{
id: 'ip_1',
status: 'paid',
payment: {
charge: {
id: 'ch_1',
paid: true,
amount_captured: 10_000,
amount_refunded: 0,
currency: 'usd',
created: 1_700_000_000,
description: null,
},
},
},
],
has_more: false,
})
mocks.refundsList.mockResolvedValue({ data: [], has_more: false })
mocks.refundsCreate.mockResolvedValue({ id: 're_1', status: 'succeeded', amount: 2500 })
})
it('uses a bounded recent paid-invoice query with expanded payment charges', async () => {
queueTableRows(subscription, [activeSubscription])
const result = await getDashboardSubscriptionBillingActions('org-1')
expect(mocks.invoicesList).toHaveBeenCalledWith({
subscription: 'sub_stripe_1',
limit: 12,
expand: ['data.payments'],
})
expect(mocks.invoicePaymentsList).toHaveBeenCalledWith(
expect.objectContaining({
invoice: 'in_1',
limit: 10,
expand: ['data.payment.charge', 'data.payment.payment_intent.latest_charge'],
})
)
expect(mocks.chargesRetrieve).not.toHaveBeenCalled()
expect(result).toMatchObject({
cancellationSync: null,
refundHistoryLimited: false,
refundablePayments: [{ chargeId: 'ch_1', refundableCents: 10_000 }],
})
})
it('uses expanded invoice payments without one extra Stripe request per invoice', async () => {
queueTableRows(subscription, [activeSubscription])
mocks.invoicesList.mockResolvedValue({
data: [
{
id: 'in_1',
description: 'Annual Enterprise invoice',
payments: {
data: [
{
id: 'ip_1',
status: 'paid',
payment: {
charge: {
id: 'ch_1',
paid: true,
amount_captured: 10_000,
amount_refunded: 0,
currency: 'usd',
created: 1_700_000_000,
description: null,
},
},
},
],
has_more: false,
},
},
],
has_more: false,
})
const result = await getDashboardSubscriptionBillingActions('org-1')
expect(mocks.invoicePaymentsList).not.toHaveBeenCalled()
expect(result.refundablePayments).toEqual([
expect.objectContaining({ chargeId: 'ch_1', refundableCents: 10_000 }),
])
})
it('surfaces a failed dashboard cancellation operation separately from DB desired state', async () => {
queueTableRows(subscription, [{ ...activeSubscription, cancelAtPeriodEnd: true }])
queueTableRows(outboxEvent, [
{
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
status: 'dead_letter',
error: 'Stripe unavailable',
},
])
const result = await getDashboardSubscriptionBillingActions('org-1')
expect(result.cancellationSync).toEqual({
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
timing: 'period_end',
status: 'failed',
error: 'Stripe unavailable',
})
})
it('replays a completed refund operation from Stripe metadata without a second mutation', async () => {
queueTableRows(subscription, [activeSubscription])
mocks.invoicePaymentsList.mockResolvedValue({
data: [
{
id: 'ip_1',
status: 'paid',
payment: {
charge: {
id: 'ch_1',
paid: true,
amount_captured: 10_000,
amount_refunded: 10_000,
currency: 'usd',
created: 1_700_000_000,
description: null,
},
},
},
],
has_more: false,
})
mocks.refundsList.mockResolvedValue({
data: [
{
id: 're_existing',
amount: 2500,
status: 'succeeded',
reason: 'requested_by_customer',
metadata: {
simAdminOperationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
organizationId: 'org-1',
simSubscriptionId: 'sub-row-1',
},
},
],
has_more: false,
})
const result = await refundDashboardSubscriptionPayment({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 2500,
reason: 'requested_by_customer',
actor,
})
expect(mocks.refundsCreate).not.toHaveBeenCalled()
expect(mocks.invoicesList).not.toHaveBeenCalled()
expect(result).toMatchObject({
refundId: 're_existing',
amountCents: 2500,
outcome: 'applied',
})
expect(mocks.recordAuditOnce).toHaveBeenCalledWith(
'admin-refund:67e55044-10b1-426f-9247-bb680e5fe0c8',
expect.objectContaining({
action: 'subscription.refunded',
resourceId: 'sub-row-1',
metadata: expect.objectContaining({ refundId: 're_existing' }),
})
)
})
it('repairs a lost audit write on exact refund replay before returning success', async () => {
mocks.refundsList.mockResolvedValue({
data: [
{
id: 're_existing',
amount: 2500,
status: 'succeeded',
reason: 'requested_by_customer',
metadata: {
simAdminOperationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
organizationId: 'org-1',
simSubscriptionId: 'sub-row-1',
},
},
],
has_more: false,
})
mocks.recordAuditOnce.mockRejectedValueOnce(new Error('database unavailable'))
const request = {
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 2500,
reason: 'requested_by_customer' as const,
actor,
}
await expect(refundDashboardSubscriptionPayment(request)).rejects.toThrow(
'database unavailable'
)
mocks.recordAuditOnce.mockResolvedValueOnce(undefined)
await expect(refundDashboardSubscriptionPayment(request)).resolves.toMatchObject({
refundId: 're_existing',
outcome: 'applied',
})
expect(mocks.refundsCreate).not.toHaveBeenCalled()
expect(mocks.recordAuditOnce).toHaveBeenCalledTimes(2)
})
it('keeps a provider-pending refund recoverable without recording it as applied', async () => {
queueTableRows(subscription, [activeSubscription])
mocks.refundsCreate.mockResolvedValue({
id: 're_pending',
amount: 2500,
status: 'pending',
metadata: {},
})
const result = await refundDashboardSubscriptionPayment({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 2500,
reason: 'requested_by_customer',
actor,
})
expect(result).toMatchObject({ refundId: 're_pending', outcome: 'pending' })
expect(mocks.recordAuditOnce).not.toHaveBeenCalled()
})
it('fails closed when the durable refund marker could be outside the bounded Stripe page', async () => {
queueTableRows(subscription, [activeSubscription])
mocks.refundsList.mockResolvedValue({ data: [], has_more: true })
await expect(
refundDashboardSubscriptionPayment({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 2500,
reason: 'requested_by_customer',
actor,
})
).rejects.toThrow('Could not safely verify this refund operation')
expect(mocks.refundsCreate).not.toHaveBeenCalled()
})
it('does not report a terminal Stripe refund failure as success', async () => {
queueTableRows(subscription, [activeSubscription])
mocks.refundsList.mockResolvedValue({
data: [
{
id: 're_failed',
amount: 2500,
status: 'failed',
reason: 'requested_by_customer',
metadata: {
simAdminOperationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
organizationId: 'org-1',
simSubscriptionId: 'sub-row-1',
},
},
],
has_more: false,
})
const result = await refundDashboardSubscriptionPayment({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 2500,
reason: 'requested_by_customer',
actor,
})
expect(result).toMatchObject({ refundId: 're_failed', outcome: 'failed' })
expect(mocks.refundsCreate).not.toHaveBeenCalled()
expect(mocks.recordAuditOnce).not.toHaveBeenCalled()
})
it('creates a refund with the durable client operation ID as Stripe idempotency key', async () => {
queueTableRows(subscription, [activeSubscription])
await refundDashboardSubscriptionPayment({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 2500,
reason: 'requested_by_customer',
actor,
})
expect(mocks.refundsCreate).toHaveBeenCalledWith(
expect.objectContaining({
charge: 'ch_1',
amount: 2500,
metadata: expect.objectContaining({ simSubscriptionId: 'sub-row-1' }),
}),
{ idempotencyKey: 'admin-refund:67e55044-10b1-426f-9247-bb680e5fe0c8' }
)
})
it('rejects a new refund above the remaining refundable balance', async () => {
queueTableRows(subscription, [activeSubscription])
mocks.invoicePaymentsList.mockResolvedValue({
data: [
{
id: 'ip_1',
status: 'paid',
payment: {
charge: {
id: 'ch_1',
paid: true,
amount_captured: 10_000,
amount_refunded: 7_500,
currency: 'usd',
created: 1_700_000_000,
description: null,
},
},
},
],
has_more: false,
})
await expect(
refundDashboardSubscriptionPayment({
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
chargeId: 'ch_1',
amountCents: 3_000,
reason: 'requested_by_customer',
actor,
})
).rejects.toThrow('remaining refundable balance')
expect(mocks.refundsCreate).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,571 @@
import { AuditAction, AuditResourceType, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import { outboxEvent, subscription } from '@sim/db/schema'
import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
import type { AdminMutationActor } from '@/lib/admin/dashboard'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
const RECENT_INVOICE_LIMIT = 12
const INVOICE_PAYMENT_LIMIT = 10
const MAX_RECENT_PAYMENT_CANDIDATES = 20
const STRIPE_LOOKUP_CONCURRENCY = 4
interface RecentSubscriptionPayment {
chargeId: string
amountCents: number
refundedCents: number
refundableCents: number
currency: string
createdAt: string
invoiceId: string | null
description: string | null
}
interface InvoicePaymentCandidate {
invoice: Stripe.Invoice
payment: Stripe.InvoicePayment
}
export class RefundOperationRejectedError extends Error {
constructor(message: string) {
super(message)
this.name = 'RefundOperationRejectedError'
}
}
function refundOutcome(status: Stripe.Refund['status']): 'applied' | 'pending' | 'failed' {
if (status === 'succeeded') return 'applied'
if (status === 'failed' || status === 'canceled') return 'failed'
return 'pending'
}
/** Runs a small number of independent Stripe reads concurrently without an unbounded fan-out. */
async function mapWithConcurrency<T, R>(
values: T[],
concurrency: number,
iteratee: (value: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(values.length)
let nextIndex = 0
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
while (nextIndex < values.length) {
const index = nextIndex
nextIndex += 1
results[index] = await iteratee(values[index])
}
})
await Promise.all(workers)
return results
}
async function getLatestOrganizationStripeSubscription(organizationId: string) {
const [row] = await db
.select()
.from(subscription)
.where(eq(subscription.referenceId, organizationId))
.orderBy(
sql`case when ${subscription.status} in ('active', 'past_due') then 0 else 1 end`,
desc(subscription.periodStart),
desc(subscription.id)
)
.limit(1)
if (!row?.stripeSubscriptionId) {
throw new Error('Stripe organization subscription not found')
}
return row
}
async function resolveInvoicePaymentCharge(
stripe: Stripe,
payment: Stripe.InvoicePayment
): Promise<Stripe.Charge | null> {
const directCharge = payment.payment.charge
if (directCharge) {
return typeof directCharge === 'string' ? stripe.charges.retrieve(directCharge) : directCharge
}
const paymentIntentValue = payment.payment.payment_intent
if (!paymentIntentValue) return null
const paymentIntent =
typeof paymentIntentValue === 'string'
? await stripe.paymentIntents.retrieve(paymentIntentValue, { expand: ['latest_charge'] })
: paymentIntentValue
const latestCharge = paymentIntent.latest_charge
if (!latestCharge) return null
return typeof latestCharge === 'string' ? stripe.charges.retrieve(latestCharge) : latestCharge
}
async function listRecentSubscriptionPayments(stripe: Stripe, stripeSubscriptionId: string) {
const invoices = await stripe.invoices.list({
subscription: stripeSubscriptionId,
limit: RECENT_INVOICE_LIMIT,
expand: ['data.payments'],
})
const paymentsByCharge = new Map<string, RecentSubscriptionPayment>()
const candidates: InvoicePaymentCandidate[] = []
const seenPaymentIds = new Set<string>()
let historyLimited = invoices.has_more
const invoicePaymentLists = await mapWithConcurrency(
invoices.data,
STRIPE_LOOKUP_CONCURRENCY,
async (invoice) => ({
invoice,
payments:
invoice.payments && !invoice.payments.has_more
? invoice.payments
: await stripe.invoicePayments.list({
invoice: invoice.id,
status: 'paid',
limit: INVOICE_PAYMENT_LIMIT,
expand: ['data.payment.charge', 'data.payment.payment_intent.latest_charge'],
}),
})
)
for (const { invoice, payments } of invoicePaymentLists) {
if (candidates.length >= MAX_RECENT_PAYMENT_CANDIDATES) {
historyLimited = true
break
}
if (payments.has_more) historyLimited = true
for (const payment of payments.data) {
if (payment.status !== 'paid' || seenPaymentIds.has(payment.id)) continue
if (candidates.length >= MAX_RECENT_PAYMENT_CANDIDATES) {
historyLimited = true
break
}
seenPaymentIds.add(payment.id)
candidates.push({ invoice, payment })
}
}
const resolvedCandidates = await mapWithConcurrency(
candidates,
STRIPE_LOOKUP_CONCURRENCY,
async (candidate) => ({
...candidate,
charge: await resolveInvoicePaymentCharge(stripe, candidate.payment),
})
)
for (const { invoice, charge } of resolvedCandidates) {
if (!charge || !charge.paid || paymentsByCharge.has(charge.id)) continue
paymentsByCharge.set(charge.id, {
chargeId: charge.id,
amountCents: charge.amount_captured,
refundedCents: charge.amount_refunded,
refundableCents: Math.max(0, charge.amount_captured - charge.amount_refunded),
currency: charge.currency,
createdAt: new Date(charge.created * 1000).toISOString(),
invoiceId: invoice.id ?? null,
description: invoice.description ?? charge.description,
})
}
return {
payments: [...paymentsByCharge.values()],
historyLimited,
}
}
async function getDashboardCancellationSync(organizationId: string, subscriptionId: string) {
const [row] = await db
.select({
operationId: sql<string | null>`${outboxEvent.payload} ->> 'operationId'`,
eventType: outboxEvent.eventType,
status: outboxEvent.status,
error: outboxEvent.lastError,
})
.from(outboxEvent)
.where(
and(
inArray(outboxEvent.eventType, [
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY,
]),
sql`${outboxEvent.payload} ->> 'organizationId' = ${organizationId}`,
sql`${outboxEvent.payload} ->> 'subscriptionId' = ${subscriptionId}`
)
)
.orderBy(desc(outboxEvent.createdAt), desc(outboxEvent.id))
.limit(1)
if (!row?.operationId) return null
return {
operationId: row.operationId,
timing:
row.eventType === OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY
? ('immediate' as const)
: ('period_end' as const),
status:
row.status === 'completed'
? ('applied' as const)
: row.status === 'dead_letter'
? ('failed' as const)
: row.status === 'processing'
? ('processing' as const)
: ('pending' as const),
error: row.status === 'dead_letter' ? row.error : null,
}
}
export async function getDashboardSubscriptionBillingActions(organizationId: string) {
const subscriptionRow = await getLatestOrganizationStripeSubscription(organizationId)
const stripe = requireStripeClient()
const [paymentHistory, cancellationSync] = await Promise.all([
listRecentSubscriptionPayments(stripe, subscriptionRow.stripeSubscriptionId as string),
getDashboardCancellationSync(organizationId, subscriptionRow.id),
])
return {
subscription: {
id: subscriptionRow.id,
stripeSubscriptionId: subscriptionRow.stripeSubscriptionId,
status: subscriptionRow.status,
cancelAtPeriodEnd: Boolean(subscriptionRow.cancelAtPeriodEnd),
periodEnd: subscriptionRow.periodEnd?.toISOString() ?? null,
},
cancellationSync,
refundHistoryLimited: paymentHistory.historyLimited,
refundablePayments: paymentHistory.payments.filter((payment) => payment.refundableCents > 0),
}
}
export async function requestDashboardSubscriptionCancellation({
organizationId,
operationId,
timing,
reason,
actor,
}: {
organizationId: string
operationId: string
timing: 'period_end' | 'immediate'
reason?: string
actor: AdminMutationActor
}) {
const cancellationEventType =
timing === 'immediate'
? OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY
: OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END
const normalizedReason =
reason ??
(timing === 'immediate'
? 'admin-dashboard-cancel-immediately'
: 'admin-dashboard-cancel-at-period-end')
const cancellation = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, organizationId)
const [existingOperation] = await tx
.select({
id: outboxEvent.id,
eventType: outboxEvent.eventType,
status: outboxEvent.status,
subscriptionId: sql<string>`${outboxEvent.payload} ->> 'subscriptionId'`,
reason: sql<string | null>`${outboxEvent.payload} ->> 'reason'`,
})
.from(outboxEvent)
.where(
and(
inArray(outboxEvent.eventType, [
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY,
]),
sql`${outboxEvent.payload} ->> 'operationId' = ${operationId}`,
sql`${outboxEvent.payload} ->> 'organizationId' = ${organizationId}`
)
)
.for('update')
.limit(1)
if (existingOperation) {
if (
existingOperation.eventType !== cancellationEventType ||
existingOperation.reason !== normalizedReason
) {
throw new Error('Cancellation operation ID was already used with different parameters')
}
if (existingOperation.status === 'dead_letter') {
if (existingOperation.eventType === OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END) {
const [restoredSubscription] = await tx
.update(subscription)
.set({ cancelAtPeriodEnd: true })
.where(
and(
eq(subscription.id, existingOperation.subscriptionId),
eq(subscription.referenceId, organizationId)
)
)
.returning({ id: subscription.id })
if (!restoredSubscription) {
throw new Error('Cancellation subscription no longer exists')
}
}
await tx
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(
and(eq(outboxEvent.id, existingOperation.id), eq(outboxEvent.status, 'dead_letter'))
)
return {
operationId,
outboxEventId: existingOperation.id,
subscriptionId: existingOperation.subscriptionId,
status: 'pending' as const,
}
}
return {
operationId,
outboxEventId: existingOperation.id,
subscriptionId: existingOperation.subscriptionId,
status:
existingOperation.status === 'completed'
? ('applied' as const)
: existingOperation.status === 'processing'
? ('processing' as const)
: ('pending' as const),
}
}
const subscriptionRows = await tx
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.orderBy(desc(subscription.periodStart), desc(subscription.id))
.for('update')
.limit(2)
if (subscriptionRows.length > 1) {
throw new Error(
'Multiple active organization subscriptions were found. Resolve them in Stripe before cancelling.'
)
}
const [subscriptionRow] = subscriptionRows
if (!subscriptionRow?.stripeSubscriptionId) {
throw new Error('Active Stripe organization subscription not found')
}
if (timing === 'immediate') {
const eventId = await enqueueOutboxEvent(
tx,
OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY,
{
operationId,
organizationId,
subscriptionId: subscriptionRow.id,
stripeSubscriptionId: subscriptionRow.stripeSubscriptionId,
reason: normalizedReason,
requestedBy: actor,
}
)
return {
operationId,
outboxEventId: eventId,
subscriptionId: subscriptionRow.id,
status: 'pending' as const,
}
}
if (!subscriptionRow.cancelAtPeriodEnd) {
await tx
.update(subscription)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscription.id, subscriptionRow.id))
}
const eventId = await enqueueOutboxEvent(
tx,
OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
{
operationId,
organizationId,
subscriptionId: subscriptionRow.id,
stripeSubscriptionId: subscriptionRow.stripeSubscriptionId,
reason: normalizedReason,
requestedBy: actor,
}
)
return {
operationId,
outboxEventId: eventId,
subscriptionId: subscriptionRow.id,
status: 'pending' as const,
}
})
return {
success: true as const,
operationId: cancellation.operationId,
status: cancellation.status,
}
}
export async function refundDashboardSubscriptionPayment({
organizationId,
operationId,
chargeId,
amountCents,
reason,
note,
actor,
}: {
organizationId: string
operationId: string
chargeId: string
amountCents: number
reason: 'duplicate' | 'fraudulent' | 'requested_by_customer'
note?: string
actor: AdminMutationActor
}) {
const stripe = requireStripeClient()
const existingRefunds = await stripe.refunds.list({ charge: chargeId, limit: 100 })
const existingRefund = existingRefunds.data.find(
(refund) => refund.metadata?.simAdminOperationId === operationId
)
if (existingRefund) {
const metadata = existingRefund.metadata ?? {}
const subscriptionId = metadata.simSubscriptionId
if (
existingRefund.amount !== amountCents ||
metadata.organizationId !== organizationId ||
!subscriptionId ||
existingRefund.reason !== reason ||
(metadata.adminNote ?? null) !== (note ?? null)
) {
throw new Error('Refund operation ID was already used with different parameters')
}
const outcome = refundOutcome(existingRefund.status)
if (outcome === 'applied') {
await recordRefundAuditOnce({
organizationId,
operationId,
chargeId,
refundId: existingRefund.id,
subscriptionId,
amountCents,
reason,
note,
actor,
})
}
return {
success: true as const,
refundId: existingRefund.id,
status: existingRefund.status,
outcome,
amountCents,
}
}
if (existingRefunds.has_more) {
throw new Error('Could not safely verify this refund operation. Use Stripe directly.')
}
const subscriptionRow = await getLatestOrganizationStripeSubscription(organizationId)
const paymentHistory = await listRecentSubscriptionPayments(
stripe,
subscriptionRow.stripeSubscriptionId as string
)
const payment = paymentHistory.payments.find((candidate) => candidate.chargeId === chargeId)
if (!payment) {
throw new RefundOperationRejectedError(
paymentHistory.historyLimited
? 'The selected payment is outside the recent refund window. Use Stripe directly.'
: 'The selected payment does not belong to this subscription'
)
}
if (amountCents > payment.amountCents) {
throw new RefundOperationRejectedError('Refund amount exceeds the payment amount')
}
if (amountCents > payment.refundableCents) {
throw new RefundOperationRejectedError('Refund amount exceeds the remaining refundable balance')
}
const refund = await stripe.refunds.create(
{
charge: chargeId,
amount: amountCents,
reason,
metadata: {
simAdminOperationId: operationId,
organizationId,
simSubscriptionId: subscriptionRow.id,
requestedByEmail: actor.email ?? 'admin-api',
...(note ? { adminNote: note } : {}),
},
},
{ idempotencyKey: `admin-refund:${operationId}` }
)
const outcome = refundOutcome(refund.status)
if (outcome === 'applied') {
await recordRefundAuditOnce({
organizationId,
operationId,
chargeId,
refundId: refund.id,
subscriptionId: subscriptionRow.id,
amountCents,
reason,
note,
actor,
})
}
return {
success: true as const,
refundId: refund.id,
status: refund.status,
outcome,
amountCents,
}
}
async function recordRefundAuditOnce({
organizationId,
operationId,
chargeId,
refundId,
subscriptionId,
amountCents,
reason,
note,
actor,
}: {
organizationId: string
operationId: string
chargeId: string
refundId: string
subscriptionId: string
amountCents: number
reason: 'duplicate' | 'fraudulent' | 'requested_by_customer'
note?: string
actor: AdminMutationActor
}): Promise<void> {
await recordAuditOnce(`admin-refund:${operationId}`, {
actorId: actor.id,
actorName: actor.name,
actorEmail: actor.email,
action: AuditAction.SUBSCRIPTION_REFUNDED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: subscriptionId,
description: `Admin issued a ${amountCents}-cent refund`,
metadata: {
organizationId,
operationId,
chargeId,
refundId,
amountCents,
reason,
note: note ?? null,
},
})
}
@@ -0,0 +1,87 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const enterpriseOwnerClaimParamsSchema = z.object({ id: z.string().min(1) })
export const enterpriseOwnerClaimQuerySchema = z.object({ token: z.string().min(1) })
export const enterpriseOwnerClaimViewSchema = z.object({
id: z.string(),
ownerEmail: z.string().email(),
organizationName: z.string(),
organizationId: z.string().nullable(),
provisioningOperationId: z.string().nullable(),
stage: z.enum([
'owner_email',
'owner_acceptance',
'activation',
'stripe_provisioning',
'complete',
]),
status: z.enum([
'sending',
'awaiting_owner',
'activating',
'provisioning',
'applied',
'failed',
'expired',
'revoked',
]),
error: z.string().nullable(),
expiresAt: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const enterpriseOwnerClaimDetailsSchema = enterpriseOwnerClaimViewSchema.extend({
invoiceAmountUsd: z.number().positive(),
billingInterval: z.enum(['month', 'year']),
seats: z.number().int().positive(),
invitations: z.number().int().nonnegative(),
workspacePreview: z
.object({
workspacesToMove: z.array(
z.object({ id: z.string(), name: z.string(), archived: z.boolean() })
),
createsDefaultWorkspace: z.boolean(),
})
.nullable(),
acceptanceReview: z
.object({
canAccept: z.boolean(),
reason: z.string().nullable(),
requiredSeats: z.number().int().positive().nullable(),
})
.nullable(),
})
export const acceptEnterpriseOwnerClaimBodySchema = z.object({
token: z.string().min(1),
disclosedWorkspaceIds: z.array(z.string().min(1)).max(1_000),
disclosedCreatesDefaultWorkspace: z.boolean(),
})
export const getEnterpriseOwnerClaimContract = defineRouteContract({
method: 'GET',
path: '/api/enterprise-owner-claims/[id]',
params: enterpriseOwnerClaimParamsSchema,
query: enterpriseOwnerClaimQuerySchema,
response: { mode: 'json', schema: enterpriseOwnerClaimDetailsSchema },
})
export const acceptEnterpriseOwnerClaimContract = defineRouteContract({
method: 'POST',
path: '/api/enterprise-owner-claims/[id]/accept',
params: enterpriseOwnerClaimParamsSchema,
body: acceptEnterpriseOwnerClaimBodySchema,
response: {
mode: 'json',
schema: z.object({
success: z.literal(true),
claim: enterpriseOwnerClaimViewSchema,
redirectPath: z.string().min(1),
}),
},
})
export type EnterpriseOwnerClaimDetails = z.output<typeof enterpriseOwnerClaimDetailsSchema>
+3 -4
View File
@@ -2,6 +2,9 @@ import { z } from 'zod'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits'
export { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits'
/**
* Shared cap for the disclosure token: the preview's id list and the accept
@@ -10,10 +13,6 @@ import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
*/
export const DISCLOSED_WORKSPACE_ID_LIMIT = 500
/** One invitation authorizes and stamps each workspace, so the fan-out is bounded. */
export const MAX_INVITE_WORKSPACES = 50
export const MAX_INVITE_EMAILS = 100
export const invitationParamsSchema = z.object({
id: z.string({ error: 'Invitation ID is required' }).min(1, 'Invitation ID is required'),
})
@@ -1,6 +1,10 @@
import { z } from 'zod'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import { adminV1IdParamsSchema, lastQueryValue } from '@/lib/api/contracts/v1/admin/shared'
import {
adminV1IdParamsSchema,
adminV1PaginationMetaSchema,
lastQueryValue,
} from '@/lib/api/contracts/v1/admin/shared'
export const adminDashboardWorkspaceSearchQuerySchema = z.object({
search: z.preprocess(
@@ -13,6 +17,12 @@ export const adminDashboardWorkspaceSearchQuerySchema = z.object({
return typeof queryValue === 'string' ? Number.parseInt(queryValue, 10) : queryValue
}, z.number().int().min(1).max(50).catch(20))
.catch(20),
offset: z
.preprocess((value) => {
const queryValue = lastQueryValue(value)
return typeof queryValue === 'string' ? Number.parseInt(queryValue, 10) : queryValue
}, z.number().int().min(0).catch(0))
.catch(0),
})
export const adminDashboardWorkspacePreflightQuerySchema = z.object({
@@ -23,6 +33,20 @@ export const adminDashboardWorkspacePreflightQuerySchema = z.object({
})
export const adminDashboardWorkspaceMoveBodySchema = z.object({
operationId: z.string().uuid(),
destinationOrganizationId: z.string().min(1).max(200),
expectedOwnerId: z.string().min(1).max(200).optional(),
})
export const adminDashboardWorkspaceMoveOperationQuerySchema = z.object({
destinationOrganizationId: z.preprocess(
lastQueryValue,
z.string({ error: 'destinationOrganizationId is required' }).min(1).max(200)
),
expectedOwnerId: z.preprocess(lastQueryValue, z.string().min(1).max(200).optional()),
})
export const adminDashboardWorkspaceMoveFollowUpRetryBodySchema = z.object({
destinationOrganizationId: z.string().min(1).max(200),
expectedOwnerId: z.string().min(1).max(200).optional(),
})
@@ -70,8 +94,28 @@ const adminDashboardWorkspacePreflightSchema = z.object({
warning: z.string().nullable(),
})
const adminDashboardWorkspaceMoveOperationSchema = adminDashboardWorkspacePreflightSchema.extend({
operationId: z.string().uuid(),
followUpJobs: z.object({
selected: z.number().int().min(0),
completed: z.number().int().min(0),
pending: z.number().int().min(0),
failedCount: z.number().int().min(0),
failed: z
.array(
z.object({
eventId: z.string(),
invitationId: z.string(),
error: z.string().nullable(),
})
)
.max(100),
}),
})
const adminDashboardWorkspaceSearchResponseSchema = z.object({
data: z.array(adminDashboardWorkspaceCandidateSchema),
pagination: adminV1PaginationMetaSchema,
})
const adminDashboardWorkspacePreflightResponseSchema = z.object({
@@ -79,7 +123,7 @@ const adminDashboardWorkspacePreflightResponseSchema = z.object({
})
const adminDashboardWorkspaceMoveResponseSchema = z.object({
data: adminDashboardWorkspacePreflightSchema,
data: adminDashboardWorkspaceMoveOperationSchema,
})
export const adminDashboardWorkspaceSearchContract = defineRouteContract({
@@ -105,6 +149,25 @@ export const adminDashboardWorkspaceMoveContract = defineRouteContract({
response: { mode: 'json', schema: adminDashboardWorkspaceMoveResponseSchema },
})
export const adminDashboardWorkspaceMoveOperationContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/dashboard/workspaces/[id]/move-operations/[operationId]',
params: adminV1IdParamsSchema.extend({ operationId: z.string().uuid() }),
query: adminDashboardWorkspaceMoveOperationQuerySchema,
response: { mode: 'json', schema: adminDashboardWorkspaceMoveResponseSchema },
})
export const adminDashboardRetryWorkspaceMoveFollowUpContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/workspaces/[id]/move-operations/[operationId]/follow-up-jobs/[jobId]/retry',
params: adminV1IdParamsSchema.extend({
operationId: z.string().uuid(),
jobId: z.string().min(1),
}),
body: adminDashboardWorkspaceMoveFollowUpRetryBodySchema,
response: { mode: 'json', schema: adminDashboardWorkspaceMoveResponseSchema },
})
export type AdminDashboardWorkspaceSearchResponse = ContractJsonResponse<
typeof adminDashboardWorkspaceSearchContract
>
@@ -112,3 +175,6 @@ export type AdminDashboardWorkspacePreflightResponse = ContractJsonResponse<
typeof adminDashboardWorkspacePreflightContract
>
export type AdminDashboardWorkspaceMoveBody = z.input<typeof adminDashboardWorkspaceMoveBodySchema>
export type AdminDashboardWorkspaceMoveOperationResponse = ContractJsonResponse<
typeof adminDashboardWorkspaceMoveOperationContract
>
@@ -7,6 +7,8 @@ import {
adminDashboardEnterprisePreflightSchema,
adminDashboardIssueEnterpriseBodySchema,
adminDashboardLimitsBodySchema,
adminDashboardMemberPreflightQuerySchema,
adminDashboardMemberPreflightSchema,
adminDashboardOrganizationDetailQuerySchema,
adminDashboardOrganizationSummarySchema,
adminDashboardUpdateMemberBodySchema,
@@ -75,7 +77,13 @@ describe('admin dashboard credit grant contract', () => {
currentEnd: '2026-09-01T00:00:00.000Z',
source: 'default',
},
usage: { usedDollars: 0.001, limitDollars: 0.001 },
usage: {
usedDollars: 0.001,
limitDollars: 0.001,
usedCredits: 0,
limitCredits: 0,
workflowRuns: 0,
},
provisioning: null,
}).success
).toBe(true)
@@ -154,6 +162,33 @@ describe('admin dashboard credit grant contract', () => {
).toBe(true)
})
it('paginates member-transfer workspaces and makes over-limit defaults explicit', () => {
expect(adminDashboardMemberPreflightQuerySchema.parse({ userId: 'user-1' })).toEqual({
userId: 'user-1',
search: '',
limit: 50,
offset: 0,
})
expect(
adminDashboardMemberPreflightSchema.safeParse({
user: { id: 'user-1', name: 'User', email: 'user@example.com' },
currentOrganization: null,
personalWorkspaces: [{ id: 'workspace-1', name: 'One', archived: false }],
workspacePagination: { total: 1_205, limit: 50, offset: 0, hasMore: true },
workspaceSelection: {
totalEligible: 1_205,
defaultSelectedIds: [],
defaultSelectedWorkspaces: [],
includesAllEligible: false,
limit: 1_000,
},
credentialDependencies: [],
canAdd: true,
reason: null,
}).success
).toBe(true)
})
it('keeps organization detail unbounded for legacy callers and supports bounded collection pages', () => {
expect(adminDashboardOrganizationDetailQuerySchema.parse({})).toEqual({
limit: 50,
@@ -10,6 +10,7 @@ import {
} from '@/lib/api/contracts/v1/admin/shared'
import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits'
const dollarAmountSchema = z
.number()
@@ -33,6 +34,8 @@ export const adminDashboardUserSchema = z.object({
email: z.string(),
activeOrganization: z.object({ id: z.string(), name: z.string() }).nullable(),
usageDollars: dollarAmountSchema,
usageCredits: z.number().int().min(0),
workflowRuns: z.number().int().min(0),
})
const adminDashboardBillingIntervalSchema = z.enum(['month', 'year'])
@@ -46,6 +49,9 @@ const adminDashboardReportingPeriodSchema = z.object({
const adminDashboardUsageSchema = z.object({
usedDollars: dollarAmountSchema,
limitDollars: dollarAmountSchema,
usedCredits: z.number().int().min(0),
limitCredits: z.number().int().min(0),
workflowRuns: z.number().int().min(0),
})
const adminDashboardWorkspaceMoveProgressSchema = z.object({
selected: z.number().int().min(0),
@@ -56,6 +62,39 @@ const adminDashboardWorkspaceMoveProgressSchema = z.object({
z.object({ eventId: z.string(), workspaceId: z.string(), error: z.string().nullable() })
),
})
const adminDashboardInvitationSpecSchema = z.object({
email: z.string().trim().email(),
role: z.enum(['admin', 'member']).default('member'),
permission: z.enum(['admin', 'write', 'read']).default('write'),
})
const adminDashboardInvitationProgressSchema = z.object({
selected: z.number().int().min(0),
completed: z.number().int().min(0),
pending: z.number().int().min(0),
failedCount: z.number().int().min(0),
failed: z.array(
z.object({ eventId: z.string(), email: z.string().email(), error: z.string().nullable() })
),
})
const adminDashboardFollowUpProgressSchema = z.object({
selected: z.number().int().min(0),
completed: z.number().int().min(0),
pending: z.number().int().min(0),
failedCount: z.number().int().min(0),
failed: z.array(
z.object({
eventId: z.string(),
kind: z.enum([
'member_reconciliation',
'personal_subscription_cancellation',
'migrated_invitation_email',
'workspace_added_email',
]),
subjectId: z.string(),
error: z.string().nullable(),
})
),
})
const adminDashboardDateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const adminDashboardInvoiceAmountSchema = z.number().min(0.01).max(10_000_000).multipleOf(0.01)
@@ -82,6 +121,8 @@ export const adminDashboardProvisioningSchema = z.object({
createdAt: z.string(),
updatedAt: z.string(),
workspaceMoves: adminDashboardWorkspaceMoveProgressSchema,
invitations: adminDashboardInvitationProgressSchema,
followUpJobs: adminDashboardFollowUpProgressSchema,
})
export const adminDashboardOrganizationSummarySchema = z.object({
@@ -142,6 +183,8 @@ export const adminDashboardOrganizationDetailSchema =
.nullable(),
historicalActorUsage: z.object({
usedDollars: dollarAmountSchema,
usedCredits: z.number().int().min(0),
workflowRuns: z.number().int().min(0),
actorCount: z.number().int().min(0),
}),
members: z.array(
@@ -153,6 +196,8 @@ export const adminDashboardOrganizationDetailSchema =
role: z.string(),
usageLimitDollars: dollarAmountSchema.nullable(),
usageDollars: dollarAmountSchema,
usageCredits: z.number().int().min(0),
workflowRuns: z.number().int().min(0),
})
),
externalCollaborators: z.array(
@@ -163,6 +208,8 @@ export const adminDashboardOrganizationDetailSchema =
workspaceCount: z.number().int().min(1),
usageLimitDollars: dollarAmountSchema.nullable(),
usageDollars: dollarAmountSchema,
usageCredits: z.number().int().min(0),
workflowRuns: z.number().int().min(0),
})
),
workspaces: z.array(z.object({ id: z.string(), name: z.string() })),
@@ -174,6 +221,7 @@ export const adminDashboardOrganizationDetailSchema =
id: z.string(),
plan: z.string(),
status: z.string().nullable(),
cancelAtPeriodEnd: z.boolean().nullable(),
periodStart: z.string().nullable(),
periodEnd: z.string().nullable(),
stripeSubscriptionId: z.string().nullable(),
@@ -201,6 +249,7 @@ export const adminDashboardIssueEnterpriseBodySchema = z
billingInterval: adminDashboardBillingIntervalSchema.default('year'),
reportingPeriodAnchorDate: adminDashboardDateOnlySchema.optional(),
workspaceIds: z.array(z.string().min(1)).max(1_000).default([]),
invitations: z.array(adminDashboardInvitationSpecSchema).max(MAX_INVITE_EMAILS).default([]),
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
seats: z.number().int().positive().max(100_000),
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(),
@@ -214,10 +263,103 @@ export const adminDashboardIssueEnterpriseBodySchema = z
})
.strict()
export const adminDashboardEnterpriseOwnerClaimBodySchema = z
.object({
ownerEmail: z.string().trim().email(),
organizationName: z.string().trim().min(1).max(120),
invoiceAmountUsd: adminDashboardInvoiceAmountSchema,
billingInterval: adminDashboardBillingIntervalSchema.default('year'),
invitations: z.array(adminDashboardInvitationSpecSchema).max(MAX_INVITE_EMAILS).default([]),
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
seats: z.number().int().positive().max(100_000),
concurrencyLimit: z.number().int().positive().max(MAX_BILLING_CONCURRENCY_LIMIT).optional(),
workflowExecutionTimeoutSeconds: z
.number()
.int()
.positive()
.max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS)
.optional(),
pausePaymentCollection: z.boolean().optional(),
})
.strict()
const adminDashboardEnterpriseOwnerClaimSchema = z.object({
id: z.string(),
ownerEmail: z.string().email(),
organizationName: z.string(),
organizationId: z.string().nullable(),
provisioningOperationId: z.string().nullable(),
stage: z.enum([
'owner_email',
'owner_acceptance',
'activation',
'stripe_provisioning',
'complete',
]),
status: z.enum([
'sending',
'awaiting_owner',
'activating',
'provisioning',
'applied',
'failed',
'expired',
'revoked',
]),
error: z.string().nullable(),
expiresAt: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
const adminDashboardEnterpriseOwnerClaimReviewSchema = z.object({
ownerEmail: z.string().email(),
organizationName: z.string(),
activationTiming: z.literal('after_owner_acceptance'),
invoiceAmountUsd: adminDashboardInvoiceAmountSchema,
billingInterval: adminDashboardBillingIntervalSchema,
usageLimitCredits: z.number().int().nonnegative(),
invitations: z.object({ requested: z.number().int().min(0).max(MAX_INVITE_EMAILS) }),
workspaces: z.object({ resolvedAtAcceptance: z.literal(true) }),
seats: z.object({
ownerSeats: z.literal(1),
newInvitationSeats: z.number().int().min(0).max(MAX_INVITE_EMAILS),
requiredSeats: z.number().int().positive(),
capacity: z.number().int().positive().max(100_000),
sufficient: z.boolean(),
}),
})
export const adminDashboardSeatsBodySchema = z.object({
seats: z.number().int().positive().max(100_000),
})
export const adminDashboardRenameOrganizationBodySchema = z.object({
name: z.string().trim().min(1).max(120),
})
export const adminDashboardInvitePeopleBodySchema = z.object({
operationId: z.string().uuid(),
emails: z.array(z.string().trim().email()).min(1).max(MAX_INVITE_EMAILS),
workspaceIds: z.array(z.string().min(1)).min(1).max(MAX_INVITE_WORKSPACES),
role: z.enum(['admin', 'member']).default('member'),
permission: z.enum(['admin', 'write', 'read']).default('write'),
})
export const adminDashboardCancelSubscriptionBodySchema = z.object({
operationId: z.string().uuid(),
timing: z.enum(['period_end', 'immediate']).default('period_end'),
reason: z.string().trim().min(1).max(500).optional(),
})
export const adminDashboardRefundBodySchema = z.object({
operationId: z.string().uuid(),
chargeId: z.string().min(1),
amountCents: z.number().int().positive(),
reason: z.enum(['duplicate', 'fraudulent', 'requested_by_customer']),
note: z.string().trim().min(1).max(500).optional(),
})
export const adminDashboardLimitsBodySchema = z
.object({
usageLimitDollars: creditAlignedDollarAmountSchema.optional(),
@@ -251,14 +393,17 @@ export const adminDashboardBalanceGrantBodySchema = z.object({
})
export const adminDashboardAddMemberBodySchema = z.object({
operationId: z.string().uuid(),
userId: z.string().min(1),
role: z.enum(['admin', 'member']),
usageLimitDollars: dollarAmountSchema.nullable().optional(),
personalWorkspaceIds: z.array(z.string().min(1)).max(100).default([]),
personalWorkspaceIds: z.array(z.string().min(1)).max(1_000).default([]),
})
export const adminDashboardMemberPreflightQuerySchema = z.object({
export const adminDashboardMemberPreflightQuerySchema = adminDashboardSearchQuerySchema.extend({
userId: z.string().min(1),
limit: adminV1PaginationQuerySchema.shape.limit.default(50),
offset: adminV1PaginationQuerySchema.shape.offset.default(0),
})
export const adminDashboardMemberPreflightSchema = z.object({
@@ -267,6 +412,35 @@ export const adminDashboardMemberPreflightSchema = z.object({
personalWorkspaces: z.array(
z.object({ id: z.string(), name: z.string(), archived: z.boolean() })
),
workspacePagination: adminV1PaginationMetaSchema,
workspaceSelection: z
.object({
totalEligible: z.number().int().min(0),
defaultSelectedIds: z.array(z.string().min(1)).max(1_000),
defaultSelectedWorkspaces: z
.array(z.object({ id: z.string(), name: z.string(), archived: z.boolean() }))
.max(1_000),
includesAllEligible: z.boolean(),
limit: z.literal(1_000),
})
.superRefine((selection, context) => {
const validCompleteSelection =
selection.includesAllEligible &&
selection.totalEligible <= selection.limit &&
selection.defaultSelectedIds.length === selection.totalEligible &&
selection.defaultSelectedWorkspaces.length === selection.totalEligible
const validBoundedSelection =
!selection.includesAllEligible &&
selection.totalEligible > selection.limit &&
selection.defaultSelectedIds.length === 0 &&
selection.defaultSelectedWorkspaces.length === 0
if (!validCompleteSelection && !validBoundedSelection) {
context.addIssue({
code: 'custom',
message: 'Default workspace selection must be complete or explicitly empty above limit',
})
}
}),
credentialDependencies: z.array(
z.object({
id: z.string(),
@@ -349,6 +523,34 @@ export const adminDashboardEnterprisePreflightSchema = z.object({
reason: z.string().nullable(),
})
export const adminDashboardEnterpriseReviewSchema = z.object({
owner: z.object({ id: z.string(), name: z.string(), email: z.string() }),
organization: z.object({ id: z.string(), name: z.string(), role: z.string() }).nullable(),
billingPreview: z.object({
reportingPeriod: adminDashboardReportingPeriodSchema,
usage: adminDashboardUsageSchema,
invoiceAmountUsd: adminDashboardInvoiceAmountSchema,
configuredUsageLimitDollars: dollarAmountSchema,
prepaidBalanceDollars: dollarAmountSchema,
effectiveUsageLimitDollars: dollarAmountSchema,
exceedsLimit: z.boolean(),
}),
workspaceSelection: z.object({ selected: z.number().int().min(0).max(1_000) }),
invitations: z.object({
requested: z.number().int().min(0).max(MAX_INVITE_EMAILS),
additionalSeatReservationsFromWorkspaceSweep: z.number().int().min(0).max(100_000),
}),
seats: z.object({
memberSeats: z.number().int().min(0),
pendingSeats: z.number().int().min(0),
migratedPendingSeats: z.number().int().min(0).max(100_000),
newInvitationSeats: z.number().int().min(0).max(MAX_INVITE_EMAILS),
requiredSeats: z.number().int().min(0),
capacity: z.number().int().positive().max(100_000),
sufficient: z.boolean(),
}),
})
export const adminDashboardBillingTermsBodySchema = z.object({
invoiceAmountUsd: z.number().min(0.01).max(10_000_000).multipleOf(0.01),
billingInterval: adminDashboardBillingIntervalSchema,
@@ -395,13 +597,103 @@ const adminDashboardBalanceGrantResultSchema = adminDashboardMutationResultSchem
prepaidBalanceDollars: dollarAmountSchema,
usageLimitDollars: dollarAmountSchema,
})
const adminDashboardMemberResultSchema = adminDashboardMutationResultSchema.extend({
memberId: z.string(),
const adminDashboardMemberOperationSchema = z.object({
id: z.string().uuid(),
organizationId: z.string(),
userId: z.string(),
status: z.enum(['pending', 'processing', 'dead_letter', 'applied']),
memberId: z.string().nullable(),
transferredFromOrganizationId: z.string().nullable(),
workspaceMoves: z.array(
z.object({ workspaceId: z.string(), success: z.boolean(), error: z.string().optional() })
error: z.string().nullable(),
createdAt: z.string(),
workspaceMoves: z.object({
selected: z.number().int().min(0).max(1_000),
moved: z.number().int().min(0).max(1_000),
pending: z.number().int().min(0).max(1_000),
failedCount: z.number().int().min(0).max(1),
failed: z.array(z.object({ workspaceId: z.string(), error: z.string() })).max(1),
}),
followUpJobs: adminDashboardFollowUpProgressSchema,
})
const adminDashboardInvitationOperationSchema = z.object({
id: z.string().uuid(),
organizationId: z.string(),
status: z.enum(['pending', 'processing', 'dead_letter', 'applied']),
error: z.string().nullable(),
createdAt: z.string(),
invitations: z.object({
selected: z.number().int().min(1).max(MAX_INVITE_EMAILS),
completed: z.number().int().min(0).max(MAX_INVITE_EMAILS),
pending: z.number().int().min(0).max(MAX_INVITE_EMAILS),
failedCount: z.number().int().min(0).max(MAX_INVITE_EMAILS),
sent: z.array(z.string().email()).max(MAX_INVITE_EMAILS),
added: z.array(z.string().email()).max(MAX_INVITE_EMAILS),
unchanged: z.array(z.string().email()).max(MAX_INVITE_EMAILS),
failed: z
.array(
z.object({ eventId: z.string(), email: z.string().email(), error: z.string().nullable() })
)
.max(MAX_INVITE_EMAILS),
}),
notifications: z.object({
selected: z.number().int().min(0),
completed: z.number().int().min(0),
pending: z.number().int().min(0),
failedCount: z.number().int().min(0),
failed: z
.array(
z.object({
eventId: z.string(),
email: z.string().email(),
workspaceId: z.string(),
error: z.string().nullable(),
})
)
.max(100),
}),
})
const adminDashboardBillingActionsSchema = z.object({
subscription: z.object({
id: z.string(),
stripeSubscriptionId: z.string(),
status: z.string().nullable(),
cancelAtPeriodEnd: z.boolean(),
periodEnd: z.string().nullable(),
}),
cancellationSync: z
.object({
operationId: z.string().uuid(),
timing: z.enum(['period_end', 'immediate']),
status: z.enum(['pending', 'processing', 'applied', 'failed']),
error: z.string().nullable(),
})
.nullable(),
refundHistoryLimited: z.boolean(),
refundablePayments: z.array(
z.object({
chargeId: z.string(),
amountCents: z.number().int().nonnegative(),
refundedCents: z.number().int().nonnegative(),
refundableCents: z.number().int().nonnegative(),
currency: z.string(),
createdAt: z.string(),
invoiceId: z.string().nullable(),
description: z.string().nullable(),
})
),
})
const adminDashboardCancellationResultSchema = z.object({
success: z.literal(true),
operationId: z.string(),
status: z.enum(['pending', 'processing', 'applied', 'failed']),
})
const adminDashboardRefundResultSchema = z.object({
success: z.literal(true),
refundId: z.string(),
status: z.string().nullable(),
outcome: z.enum(['applied', 'pending', 'failed']),
amountCents: z.number().int().positive(),
})
export const adminDashboardListUsersContract = defineRouteContract({
method: 'GET',
@@ -431,6 +723,17 @@ export const adminDashboardGetOrganizationContract = defineRouteContract({
},
})
export const adminDashboardRenameOrganizationContract = defineRouteContract({
method: 'PATCH',
path: '/api/v1/admin/dashboard/organizations/[id]',
params: adminV1IdParamsSchema,
body: adminDashboardRenameOrganizationBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardMutationResultSchema),
},
})
export const adminDashboardIssueEnterpriseContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-provisioning',
@@ -441,6 +744,56 @@ export const adminDashboardIssueEnterpriseContract = defineRouteContract({
},
})
export const adminDashboardCreateEnterpriseOwnerClaimContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-owner-claims',
body: adminDashboardEnterpriseOwnerClaimBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardEnterpriseOwnerClaimSchema),
},
})
export const adminDashboardListEnterpriseOwnerClaimsContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/dashboard/enterprise-owner-claims',
query: adminV1PaginationQuerySchema,
response: {
mode: 'json',
schema: adminV1ListResponseSchema(adminDashboardEnterpriseOwnerClaimSchema),
},
})
export const adminDashboardReviewEnterpriseOwnerClaimContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-owner-claims/review',
body: adminDashboardEnterpriseOwnerClaimBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardEnterpriseOwnerClaimReviewSchema),
},
})
export const adminDashboardRetryEnterpriseOwnerClaimContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-owner-claims/[id]/retry',
params: adminV1IdParamsSchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardEnterpriseOwnerClaimSchema),
},
})
export const adminDashboardRevokeEnterpriseOwnerClaimContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-owner-claims/[id]/revoke',
params: adminV1IdParamsSchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardEnterpriseOwnerClaimSchema),
},
})
export const adminDashboardEnterprisePreflightContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/dashboard/enterprise-provisioning/preflight',
@@ -451,6 +804,16 @@ export const adminDashboardEnterprisePreflightContract = defineRouteContract({
},
})
export const adminDashboardEnterpriseReviewContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-provisioning/review',
body: adminDashboardIssueEnterpriseBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardEnterpriseReviewSchema),
},
})
export const adminDashboardRetryEnterpriseContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-provisioning/[id]/retry',
@@ -471,6 +834,26 @@ export const adminDashboardRetryEnterpriseWorkspaceMoveContract = defineRouteCon
},
})
export const adminDashboardRetryEnterpriseInvitationContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-provisioning/[id]/invitations/[inviteId]/retry',
params: adminV1IdParamsSchema.extend({ inviteId: z.string().min(1) }),
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardProvisioningSchema),
},
})
export const adminDashboardRetryEnterpriseFollowUpJobContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/enterprise-provisioning/[id]/follow-up-jobs/[jobId]/retry',
params: adminV1IdParamsSchema.extend({ jobId: z.string().min(1) }),
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardProvisioningSchema),
},
})
export const adminDashboardUpdateSeatsContract = defineRouteContract({
method: 'PATCH',
path: '/api/v1/admin/dashboard/organizations/[id]/seats',
@@ -553,7 +936,33 @@ export const adminDashboardAddMemberContract = defineRouteContract({
path: '/api/v1/admin/dashboard/organizations/[id]/members',
params: adminV1IdParamsSchema,
body: adminDashboardAddMemberBodySchema,
response: { mode: 'json', schema: adminV1SingleResponseSchema(adminDashboardMemberResultSchema) },
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardMemberOperationSchema),
},
})
export const adminDashboardMemberOperationContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/dashboard/organizations/[id]/member-operations/[operationId]',
params: adminV1IdParamsSchema.extend({ operationId: z.string().uuid() }),
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardMemberOperationSchema),
},
})
export const adminDashboardRetryMemberFollowUpJobContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/organizations/[id]/member-operations/[operationId]/follow-up-jobs/[jobId]/retry',
params: adminV1IdParamsSchema.extend({
operationId: z.string().uuid(),
jobId: z.string().min(1),
}),
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardMemberOperationSchema),
},
})
export const adminDashboardMemberPreflightContract = defineRouteContract({
@@ -567,6 +976,72 @@ export const adminDashboardMemberPreflightContract = defineRouteContract({
},
})
export const adminDashboardInvitePeopleContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/organizations/[id]/invitations',
params: adminV1IdParamsSchema,
body: adminDashboardInvitePeopleBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardInvitationOperationSchema),
},
})
export const adminDashboardGetInvitationOperationContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/dashboard/organizations/[id]/invitation-operations/[operationId]',
params: adminV1IdParamsSchema.extend({ operationId: z.string().uuid() }),
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardInvitationOperationSchema),
},
})
export const adminDashboardRetryInvitationOperationJobContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/organizations/[id]/invitation-operations/[operationId]/jobs/[jobId]/retry',
params: adminV1IdParamsSchema.extend({
operationId: z.string().uuid(),
jobId: z.string().min(1),
}),
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardInvitationOperationSchema),
},
})
export const adminDashboardBillingActionsContract = defineRouteContract({
method: 'GET',
path: '/api/v1/admin/dashboard/organizations/[id]/billing-actions',
params: adminV1IdParamsSchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardBillingActionsSchema),
},
})
export const adminDashboardCancelSubscriptionContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/organizations/[id]/cancellation',
params: adminV1IdParamsSchema,
body: adminDashboardCancelSubscriptionBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardCancellationResultSchema),
},
})
export const adminDashboardRefundContract = defineRouteContract({
method: 'POST',
path: '/api/v1/admin/dashboard/organizations/[id]/refunds',
params: adminV1IdParamsSchema,
body: adminDashboardRefundBodySchema,
response: {
mode: 'json',
schema: adminV1SingleResponseSchema(adminDashboardRefundResultSchema),
},
})
export const adminDashboardUpdateMemberContract = defineRouteContract({
method: 'PATCH',
path: '/api/v1/admin/dashboard/organizations/[id]/members/[memberId]',
@@ -0,0 +1,81 @@
/** @vitest-environment node */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { getOrganizationSubscription, getBillingPeriodUsageCostByUser } = vi.hoisted(() => ({
getOrganizationSubscription: vi.fn(),
getBillingPeriodUsageCostByUser: vi.fn(),
}))
vi.mock('@/lib/billing/core/billing', () => ({
getOrganizationSubscription,
getPlanPricing: vi.fn(),
}))
vi.mock('@/lib/billing/core/usage-log', () => ({
getBillingPeriodUsageCost: vi.fn(),
getBillingPeriodUsageCostByUser,
}))
import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization'
describe('getOrganizationMemberUsageSnapshot', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-20T12:00:00.000Z'))
getBillingPeriodUsageCostByUser.mockResolvedValue(new Map([['user-1', 12.5]]))
})
afterEach(() => vi.useRealTimers())
it('uses the Enterprise reporting window and excludes the legacy baseline', async () => {
getOrganizationSubscription.mockResolvedValue({
plan: 'enterprise',
billingInterval: 'year',
metadata: { reportingPeriodAnchorDate: '2026-01-01' },
periodStart: new Date('2026-08-01T00:00:00.000Z'),
periodEnd: new Date('2026-09-01T00:00:00.000Z'),
})
const snapshot = await getOrganizationMemberUsageSnapshot('org-1', {
userIds: ['user-1'],
})
expect(snapshot.billingPeriod).toMatchObject({
source: 'reporting',
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2027-01-01T00:00:00.000Z'),
})
expect(snapshot.includeLegacyBaseline).toBe(false)
expect(getBillingPeriodUsageCostByUser).toHaveBeenCalledWith(
{ type: 'organization', id: 'org-1' },
expect.objectContaining({ source: 'reporting' }),
undefined,
expect.anything(),
['user-1']
)
})
it('uses Stripe dates and retains the legacy baseline without custom reporting metadata', async () => {
const periodStart = new Date('2026-08-01T00:00:00.000Z')
const periodEnd = new Date('2026-09-01T00:00:00.000Z')
getOrganizationSubscription.mockResolvedValue({
plan: 'enterprise',
billingInterval: 'month',
metadata: {},
periodStart,
periodEnd,
})
const snapshot = await getOrganizationMemberUsageSnapshot('org-1')
expect(snapshot.billingPeriod).toEqual({
source: 'stripe',
start: periodStart,
end: periodEnd,
anchorDate: null,
interval: 'month',
})
expect(snapshot.includeLegacyBaseline).toBe(true)
})
})
+4 -4
View File
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, count, eq, gte, lt, sql } from 'drizzle-orm'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
import { resolveSubscriptionUsagePeriod } from '@/lib/billing/core/reporting-period'
import { resolveSubscriptionUsagePeriodOrDefault } from '@/lib/billing/core/reporting-period'
import {
getBillingPeriodUsageCost,
getBillingPeriodUsageCostByUser,
@@ -82,7 +82,7 @@ export async function getOrgMemberLedgerByUser(
let billingPeriod = period ?? null
if (period === undefined) {
const subscription = await getOrganizationSubscription(organizationId, { executor })
billingPeriod = resolveSubscriptionUsagePeriod(subscription)
billingPeriod = subscription ? resolveSubscriptionUsagePeriodOrDefault(subscription) : null
}
if (!billingPeriod) return new Map<string, number>()
return getBillingPeriodUsageCostByUser(
@@ -176,7 +176,7 @@ export async function getOrganizationMemberUsageSnapshot(
): Promise<OrganizationMemberUsageSnapshot> {
const executor = options.executor ?? db
const subscription = await getOrganizationSubscription(organizationId, { executor })
const billingPeriod = resolveSubscriptionUsagePeriod(subscription)
const billingPeriod = subscription ? resolveSubscriptionUsagePeriodOrDefault(subscription) : null
return {
billingPeriod,
includeLegacyBaseline: billingPeriod?.source !== 'reporting',
@@ -217,7 +217,7 @@ export async function getOrganizationBillingData(
return null
}
const billingPeriod = resolveSubscriptionUsagePeriod(subscription)
const billingPeriod = resolveSubscriptionUsagePeriodOrDefault(subscription)
const includeLegacyBaseline = billingPeriod?.source !== 'reporting'
const limit = Math.min(
MAX_ORGANIZATION_BILLING_MEMBER_LIMIT,
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
resolveEnterpriseReportingPeriod,
resolveSubscriptionUsagePeriod,
resolveSubscriptionUsagePeriodOrDefault,
} from '@/lib/billing/core/reporting-period'
describe('Enterprise reporting periods', () => {
@@ -59,4 +60,16 @@ describe('Enterprise reporting periods', () => {
)
).toMatchObject({ source: 'stripe' })
})
it('uses the same open fallback window when a subscription has no usable dates', () => {
expect(
resolveSubscriptionUsagePeriodOrDefault({ plan: 'enterprise', metadata: {} })
).toMatchObject({
start: new Date(0),
end: new Date(Date.UTC(9999, 11, 31)),
source: 'default',
anchorDate: null,
interval: null,
})
})
})
@@ -1,4 +1,5 @@
import { isRecordLike } from '@sim/utils/object'
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
import { isEnterprise } from '@/lib/billing/plan-helpers'
export const ENTERPRISE_REPORTING_PERIOD_ANCHOR_METADATA_KEY = 'reportingPeriodAnchorDate'
@@ -110,3 +111,18 @@ export function resolveSubscriptionUsagePeriod(
return null
}
/** Resolves a paid subscription's canonical usage window, including the open fallback window. */
export function resolveSubscriptionUsagePeriodOrDefault(
subscription: SubscriptionPeriodInput,
now: Date = new Date()
): ResolvedUsagePeriod {
return (
resolveSubscriptionUsagePeriod(subscription, now) ?? {
...defaultBillingPeriod(),
source: 'default',
anchorDate: null,
interval: null,
}
)
}
+37
View File
@@ -215,6 +215,43 @@ export async function getBillingPeriodUsageCost(
return Number.parseFloat(row?.cost ?? '0')
}
/**
* Counts distinct workflow executions that produced billable ledger entries in
* an attributed billing period. Multiple line items for one execution count as
* one run; executions with no billable usage are intentionally excluded.
*/
export async function getBillingPeriodWorkflowRunCount(
billingEntity: BillingEntity,
billingPeriod: UsageQueryPeriod,
executor: DbClient = db
): Promise<number> {
const [row] = await executor
.select({
workflowRuns:
sql<number>`COUNT(DISTINCT ${usageLog.executionId}) FILTER (WHERE ${usageLog.source} = 'workflow')`.mapWith(
Number
),
})
.from(usageLog)
.where(
and(
eq(usageLog.billingEntityType, billingEntity.type),
eq(usageLog.billingEntityId, billingEntity.id),
...(billingPeriod.source === 'reporting'
? [
gte(usageLog.createdAt, billingPeriod.start),
lt(usageLog.createdAt, billingPeriod.end),
]
: [
eq(usageLog.billingPeriodStart, billingPeriod.start),
eq(usageLog.billingPeriodEnd, billingPeriod.end),
])
)
)
return row?.workflowRuns ?? 0
}
/**
* Period total plus the portion attributable to `source`, in a single scan.
*
+39 -1
View File
@@ -6,11 +6,13 @@ import { z } from 'zod'
import { MAX_BILLING_CONCURRENCY_LIMIT } from '@/lib/billing/concurrency-defaults'
import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults'
import type { DbOrTx } from '@/lib/db/types'
import { MAX_INVITE_EMAILS } from '@/lib/invitations/limits'
export const ENTERPRISE_PROVISION_EVENT_TYPE = 'stripe.provision-enterprise'
export const ENTERPRISE_METADATA_SYNC_EVENT_TYPE = 'stripe.sync-enterprise-metadata'
export const ENTERPRISE_WORKSPACE_MOVE_EVENT_TYPE = 'enterprise.move-workspace'
export const ENTERPRISE_MEMBER_RECONCILIATION_EVENT_TYPE = 'enterprise.reconcile-members'
export const ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE = 'enterprise.invite-people'
const nonnegativeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
@@ -20,6 +22,7 @@ export const enterpriseProvisionRequestSchema = z.object({
organizationId: z.string().min(1),
requestedByEmail: z.string().min(1),
requestedByUserId: z.string().nullable(),
requestedByName: z.string().min(1).default('Admin Panel'),
invoiceAmountCents: z.number().int().positive(),
billingInterval: z.enum(['month', 'year']).default('month'),
reportingPeriodAnchorDate: z
@@ -27,6 +30,16 @@ export const enterpriseProvisionRequestSchema = z.object({
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
workspaceIds: z.array(z.string().min(1)).max(1_000).default([]),
invitations: z
.array(
z.object({
email: z.string().email(),
role: z.enum(['admin', 'member']),
permission: z.enum(['admin', 'write', 'read']),
})
)
.max(MAX_INVITE_EMAILS)
.default([]),
usageLimitCredits: nonnegativeInteger,
prepaidBalanceCreditsAtIssuance: nonnegativeInteger.default(0),
seats: z.number().int().positive(),
@@ -38,6 +51,7 @@ export const enterpriseProvisionRequestSchema = z.object({
.max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS)
.optional(),
pausePaymentCollection: z.boolean().default(false),
logoutOwnerOnApply: z.boolean().default(false),
})
export const enterpriseProvisionPayloadSchema = z.object({
@@ -153,14 +167,38 @@ export const enterpriseWorkspaceMovePayloadSchema = z.object({
workspaceId: z.string().min(1),
destinationOrganizationId: z.string().min(1),
expectedOwnerId: z.string().min(1),
adminEmail: z.string().email(),
adminUserId: z.string().min(1).nullable().default(null),
adminName: z.string().min(1).default('Admin Panel'),
adminEmail: z.string().min(1),
sequence: z.number().int().min(0),
})
export type EnterpriseWorkspaceMovePayload = z.infer<typeof enterpriseWorkspaceMovePayloadSchema>
export const enterpriseInvitePeoplePayloadSchema = z.object({
source: z.enum(['enterprise', 'admin']).default('enterprise'),
provisioningOperationId: z.string().min(1),
organizationId: z.string().min(1),
ownerUserId: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'member']),
permission: z.enum(['admin', 'write', 'read']),
sequence: z.number().int().min(0),
attemptedAt: z.string().datetime().optional(),
delivery: z
.object({
completedAt: z.string().datetime(),
resultId: z.string().min(1),
outcome: z.enum(['sent', 'added', 'unchanged']).default('sent'),
})
.optional(),
})
export type EnterpriseInvitePeoplePayload = z.infer<typeof enterpriseInvitePeoplePayloadSchema>
export const enterpriseMemberReconciliationPayloadSchema = z.object({
organizationId: z.string().min(1),
provisioningOperationId: z.string().min(1).nullable().default(null),
afterUserId: z.string().min(1).nullable().default(null),
})
@@ -0,0 +1,408 @@
/**
* @vitest-environment node
*/
import { db } from '@sim/db'
import { member, outboxEvent, user, workspace } from '@sim/db/schema'
import { queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
recordAuditOnce: vi.fn(),
assertInvitationEligibility: vi.fn(),
getSeatRequirement: vi.fn(),
getProvisioning: vi.fn(),
issueProvisioning: vi.fn(),
retryProvisioning: vi.fn(),
acquireUserLock: vi.fn(),
createOrganization: vi.fn(),
enqueue: vi.fn(),
patchPayload: vi.fn(),
process: vi.fn(),
sendEmail: vi.fn(),
createDefaultWorkspace: vi.fn(),
emitWorkspaceCreatedPlatformEvent: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
AuditAction: {
ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned',
ORGANIZATION_CREATED: 'organization.created',
INVITATION_REVOKED: 'invitation.revoked',
},
AuditResourceType: { SUBSCRIPTION: 'subscription', ORGANIZATION: 'organization' },
recordAuditOnce: mocks.recordAuditOnce,
}))
vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'generated-id') }))
vi.mock('@/components/emails', () => ({
getEmailSubject: vi.fn(() => 'Enterprise owner invitation'),
renderEnterpriseOwnerInvitationEmail: vi.fn(async () => '<p>Invite</p>'),
}))
vi.mock('@/lib/billing/enterprise-provisioning', () => {
class EnterpriseProvisioningError extends Error {}
return {
EnterpriseProvisioningError,
MAX_ENTERPRISE_WORKSPACE_SELECTION: 1_000,
assertEnterpriseInvitationEligibility: mocks.assertInvitationEligibility,
getEnterpriseIssuanceSeatRequirement: mocks.getSeatRequirement,
getEnterpriseProvisioningById: mocks.getProvisioning,
issueEnterpriseProvisioning: mocks.issueProvisioning,
retryEnterpriseProvisioning: mocks.retryProvisioning,
}
})
vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({
acquireUserBillingIdentityLock: mocks.acquireUserLock,
}))
vi.mock('@/lib/billing/organizations/create-organization', () => ({
createOrganizationWithOwnerTx: mocks.createOrganization,
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: mocks.enqueue,
patchOutboxEventPayload: mocks.patchPayload,
processOutboxEventById: mocks.process,
}))
vi.mock('@/lib/core/utils/urls', () => ({
SITE_URL: 'https://sim.ai',
getBaseUrl: vi.fn(() => 'https://sim.ai'),
}))
vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mocks.sendEmail }))
vi.mock('@/lib/workspaces/create', () => ({
createDefaultPersonalWorkspaceInTransaction: mocks.createDefaultWorkspace,
emitWorkspaceCreatedPlatformEvent: mocks.emitWorkspaceCreatedPlatformEvent,
}))
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
ownedAttachableWorkspacesWhere: vi.fn(() => undefined),
}))
import {
acceptEnterpriseOwnerClaim,
enterpriseOwnerClaimOutboxHandlers,
getEnterpriseOwnerClaimDetails,
retryEnterpriseOwnerClaim,
reviewEnterpriseOwnerClaim,
revokeEnterpriseOwnerClaim,
} from '@/lib/billing/enterprise-owner-claim'
const now = new Date('2026-08-20T12:00:00.000Z')
const request = {
requestKey: 'request-key',
ownerEmail: 'owner@example.com',
organizationName: 'Acme',
requestedByEmail: 'admin@sim.ai',
requestedByUserId: 'admin-1',
requestedByName: 'Admin',
invoiceAmountCents: 120_000,
billingInterval: 'year' as const,
invitations: [
{
email: 'teammate@example.com',
role: 'member' as const,
permission: 'write' as const,
},
],
usageLimitCredits: 240_000,
seats: 2,
pausePaymentCollection: false,
}
function claimPayload() {
return {
version: 1 as const,
request,
token: 'secure-token',
expiresAt: '2026-08-27T12:00:00.000Z',
delivery: { sentAt: '2026-08-20T12:00:00.000Z' },
}
}
function claimRow(payload: Record<string, unknown> = claimPayload()) {
return {
id: 'claim-1',
eventType: 'enterprise.invite-owner',
payload,
status: 'completed',
attempts: 1,
maxAttempts: 10,
availableAt: now,
lockedAt: null,
lastError: null,
createdAt: now,
processedAt: now,
}
}
afterAll(() => {
vi.useRealTimers()
resetDbChainMock()
})
describe('Enterprise future-owner claims', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
vi.useFakeTimers()
vi.setSystemTime(now)
mocks.recordAuditOnce.mockResolvedValue(undefined)
mocks.assertInvitationEligibility.mockResolvedValue(undefined)
mocks.getSeatRequirement.mockResolvedValue({ requiredSeats: 2 })
mocks.createOrganization.mockResolvedValue({ organizationId: 'org-1', memberId: 'member-1' })
mocks.enqueue.mockResolvedValue('generated-id')
mocks.patchPayload.mockResolvedValue(undefined)
mocks.process.mockResolvedValue(undefined)
mocks.getProvisioning.mockResolvedValue({
status: 'applied',
error: null,
updatedAt: now.toISOString(),
})
})
it('rejects the future-owner path when an account already exists', async () => {
queueTableRows(user, [{ id: 'existing-owner' }])
await expect(
reviewEnterpriseOwnerClaim({
ownerEmail: 'OWNER@example.com',
organizationName: 'Acme',
invoiceAmountUsd: 1_200,
invitations: [],
seats: 1,
requestedByEmail: 'admin@sim.ai',
requestedByUserId: 'admin-1',
})
).rejects.toThrow('A Sim account now exists')
expect(mocks.assertInvitationEligibility).not.toHaveBeenCalled()
})
it('does not create anything when the disclosed workspace set changed', async () => {
queueTableRows(outboxEvent, [claimRow()])
queueTableRows(member, [])
queueTableRows(workspace, [{ id: 'workspace-new' }])
await expect(
acceptEnterpriseOwnerClaim({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
userName: 'Owner',
disclosedWorkspaceIds: ['workspace-old'],
disclosedCreatesDefaultWorkspace: false,
})
).resolves.toEqual({ success: false, kind: 'disclosure-outdated' })
expect(mocks.createOrganization).not.toHaveBeenCalled()
expect(mocks.enqueue).not.toHaveBeenCalled()
expect(mocks.process).not.toHaveBeenCalled()
})
it('surfaces canonical invitation workspace limits before the owner accepts', async () => {
queueTableRows(outboxEvent, [claimRow()])
queueTableRows(
workspace,
Array.from({ length: 51 }, (_, index) => ({
id: `workspace-${index + 1}`,
name: `Workspace ${index + 1}`,
archivedAt: null,
}))
)
queueTableRows(member, [])
const details = await getEnterpriseOwnerClaimDetails({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
})
expect(details).toMatchObject({
acceptanceReview: {
canAccept: false,
requiredSeats: null,
reason: expect.stringContaining('more than 50 workspaces'),
},
})
expect(details?.workspacePreview?.workspacesToMove).toHaveLength(51)
expect(mocks.getSeatRequirement).not.toHaveBeenCalled()
})
it('atomically creates ownership and enqueues activation after exact consent', async () => {
queueTableRows(outboxEvent, [claimRow()])
queueTableRows(member, [])
queueTableRows(workspace, [{ id: 'workspace-1' }])
mocks.process.mockImplementationOnce(async () => {
const acceptance = mocks.patchPayload.mock.calls[0]?.[2]?.acceptance
const acceptedPayload = { ...claimPayload(), acceptance }
queueTableRows(outboxEvent, [claimRow(acceptedPayload)])
queueTableRows(outboxEvent, [
{
...claimRow({
claimId: 'claim-1',
provisioningOperationId: 'provisioning-1',
}),
id: 'generated-id',
eventType: 'enterprise.activate-owner-claim',
},
])
})
const result = await acceptEnterpriseOwnerClaim({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
userName: 'Owner',
disclosedWorkspaceIds: ['workspace-1'],
disclosedCreatesDefaultWorkspace: false,
})
expect(result).toMatchObject({
success: true,
claim: { organizationId: 'org-1', status: 'applied', stage: 'complete' },
})
expect(mocks.getSeatRequirement).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: null,
workspaceIds: ['workspace-1'],
existingSeatEmails: ['owner@example.com'],
})
)
expect(mocks.createOrganization).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ ownerUserId: 'owner-1', name: 'Acme' })
)
expect(mocks.patchPayload).toHaveBeenCalledWith(
expect.anything(),
'claim-1',
expect.objectContaining({
acceptance: expect.objectContaining({
organizationId: 'org-1',
ownerUserId: 'owner-1',
workspaceIds: ['workspace-1'],
reportingPeriodAnchorDate: '2026-08-20',
}),
})
)
expect(mocks.enqueue).toHaveBeenCalledWith(
expect.anything(),
'enterprise.activate-owner-claim',
{ claimId: 'claim-1' },
{ id: 'generated-id' }
)
})
it('activates through the canonical Enterprise issuance operation only after acceptance', async () => {
const accepted = {
acceptedAt: '2026-08-20T12:00:00.000Z',
ownerUserId: 'owner-1',
organizationId: 'org-1',
workspaceIds: ['workspace-1'],
reportingPeriodAnchorDate: '2026-08-20',
activationEventId: 'activation-1',
createdDefaultWorkspaceId: null,
}
queueTableRows(outboxEvent, [claimRow({ ...claimPayload(), acceptance: accepted })])
mocks.issueProvisioning.mockResolvedValue({ id: 'provisioning-1' })
const checkpointPayload = vi.fn()
await enterpriseOwnerClaimOutboxHandlers['enterprise.activate-owner-claim'](
{ claimId: 'claim-1' },
{
eventId: 'activation-1',
eventType: 'enterprise.activate-owner-claim',
attempts: 0,
maxAttempts: 10,
signal: new AbortController().signal,
checkpointPayload,
}
)
expect(mocks.issueProvisioning).toHaveBeenCalledWith(
expect.objectContaining({
ownerUserId: 'owner-1',
organizationName: 'Acme',
workspaceIds: ['workspace-1'],
reportingPeriodAnchorDate: '2026-08-20',
invitations: request.invitations,
})
)
expect(checkpointPayload).toHaveBeenCalledWith({
provisioningOperationId: 'provisioning-1',
})
expect(mocks.patchPayload).toHaveBeenCalledWith(db, 'claim-1', {
provisioningOperationId: 'provisioning-1',
})
})
it('revokes an unaccepted claim without starting activation', async () => {
const revokedAt = now.toISOString()
queueTableRows(outboxEvent, [claimRow()])
queueTableRows(outboxEvent, [claimRow({ ...claimPayload(), revokedAt })])
const result = await revokeEnterpriseOwnerClaim('claim-1', {
id: 'admin-1',
name: 'Admin',
email: 'admin@sim.ai',
})
expect(result).toMatchObject({ id: 'claim-1', status: 'revoked' })
expect(mocks.process).not.toHaveBeenCalled()
expect(mocks.issueProvisioning).not.toHaveBeenCalled()
expect(mocks.recordAuditOnce).toHaveBeenCalledWith(
'claim-1:revoked',
expect.objectContaining({
action: 'invitation.revoked',
resourceId: 'claim-1',
})
)
})
it('rejects acceptance after an invitation is revoked', async () => {
queueTableRows(outboxEvent, [
claimRow({ ...claimPayload(), revokedAt: '2026-08-20T11:00:00.000Z' }),
])
await expect(
acceptEnterpriseOwnerClaim({
claimId: 'claim-1',
token: 'secure-token',
userId: 'owner-1',
userEmail: 'owner@example.com',
userName: 'Owner',
disclosedWorkspaceIds: [],
disclosedCreatesDefaultWorkspace: true,
})
).resolves.toEqual({ success: false, kind: 'revoked' })
expect(mocks.createOrganization).not.toHaveBeenCalled()
expect(mocks.enqueue).not.toHaveBeenCalled()
})
it('repairs the parent claim when activation already attached provisioning', async () => {
const accepted = {
acceptedAt: '2026-08-20T12:00:00.000Z',
ownerUserId: 'owner-1',
organizationId: 'org-1',
workspaceIds: ['workspace-1'],
reportingPeriodAnchorDate: '2026-08-20',
activationEventId: 'activation-1',
createdDefaultWorkspaceId: null,
}
const acceptedPayload = { ...claimPayload(), acceptance: accepted }
const activationRow = {
...claimRow({ claimId: 'claim-1', provisioningOperationId: 'provisioning-1' }),
id: 'activation-1',
eventType: 'enterprise.activate-owner-claim',
}
queueTableRows(outboxEvent, [claimRow(acceptedPayload)])
queueTableRows(outboxEvent, [activationRow])
queueTableRows(outboxEvent, [
claimRow({ ...acceptedPayload, provisioningOperationId: 'provisioning-1' }),
])
queueTableRows(outboxEvent, [activationRow])
const result = await retryEnterpriseOwnerClaim('claim-1')
expect(result).toMatchObject({ status: 'applied', provisioningOperationId: 'provisioning-1' })
expect(mocks.patchPayload).toHaveBeenCalledWith(expect.anything(), 'claim-1', {
provisioningOperationId: 'provisioning-1',
})
expect(mocks.issueProvisioning).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
@@ -21,12 +21,16 @@ const mocks = vi.hoisted(() => ({
enqueue: vi.fn(),
patchPayload: vi.fn(),
reapplyPaidOrgJoinBillingForExistingMemberTx: vi.fn(),
prepareWorkspaceInvitationContext: vi.fn(),
createWorkspaceInvitation: vi.fn(),
sendInvitationEmail: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
AuditAction: { ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned' },
AuditResourceType: { SUBSCRIPTION: 'subscription' },
recordAudit: vi.fn(),
recordAuditOnce: vi.fn(),
}))
vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'generated-id') }))
@@ -64,17 +68,28 @@ vi.mock('@/lib/core/outbox/service', () => ({
...(consumeAttempt ? {} : { consumeAttempt: false }),
}),
enqueueOutboxEvent: mocks.enqueue,
outboxEventHasSourceOperationId: vi.fn(() => undefined),
patchOutboxEventPayload: mocks.patchPayload,
}))
vi.mock('@/lib/invitations/workspace-invitations', () => ({
prepareWorkspaceInvitationContext: mocks.prepareWorkspaceInvitationContext,
createWorkspaceInvitation: mocks.createWorkspaceInvitation,
}))
vi.mock('@/lib/invitations/send', () => ({
sendInvitationEmail: mocks.sendInvitationEmail,
}))
import {
buildEnterpriseProvisioningRequestKey,
computeEnterpriseIssuanceRequiredSeats,
decideEnterpriseProvisioningIssue,
decideEnterpriseProvisioningRetry,
getEnterpriseIssuancePreflight,
getLatestEnterpriseProvisionings,
inviteEnterprisePeople,
provisionEnterpriseInStripe,
reconcileEnterpriseMembers,
reviewEnterpriseProvisioning,
syncEnterpriseMetadataInStripe,
} from '@/lib/billing/enterprise-provisioning'
@@ -207,6 +222,74 @@ describe('Enterprise issuance preflight', () => {
})
})
it('reviews exact selected-workspace invitation reservations before issuance', async () => {
queueTableRows(schemaMock.user, [{ id: 'owner-1', name: 'Owner', email: 'owner@example.com' }])
queueTableRows(schemaMock.member, [])
queueTableRows(schemaMock.workspace, [{ value: 1 }])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1', name: 'One', archivedAt: null }])
queueTableRows(schemaMock.workspace, [
{ id: 'workspace-1', name: 'One', archivedAt: null, total: 1 },
])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
queueTableRows(schemaMock.invitation, [{ email: 'pending@example.com' }])
await expect(
reviewEnterpriseProvisioning({
ownerUserId: 'owner-1',
organizationName: 'Acme',
invoiceAmountUsd: 1_200,
billingInterval: 'year',
reportingPeriodAnchorDate: '2026-08-01',
workspaceIds: ['workspace-1'],
invitations: [],
seats: 1,
})
).resolves.toMatchObject({
workspaceSelection: { selected: 1 },
invitations: {
requested: 0,
additionalSeatReservationsFromWorkspaceSweep: 1,
},
seats: {
memberSeats: 1,
pendingSeats: 0,
migratedPendingSeats: 1,
newInvitationSeats: 0,
requiredSeats: 2,
capacity: 1,
sufficient: false,
},
})
})
it('blocks an oversized workspace-sweep invitation expansion without truncating it', async () => {
queueTableRows(schemaMock.user, [{ id: 'owner-1', name: 'Owner', email: 'owner@example.com' }])
queueTableRows(schemaMock.member, [])
queueTableRows(schemaMock.workspace, [{ value: 1 }])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1', name: 'One', archivedAt: null }])
queueTableRows(schemaMock.workspace, [
{ id: 'workspace-1', name: 'One', archivedAt: null, total: 1 },
])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
queueTableRows(
schemaMock.invitation,
Array.from({ length: 10_001 }, (_, index) => ({ email: `pending-${index}@example.com` }))
)
await expect(
reviewEnterpriseProvisioning({
ownerUserId: 'owner-1',
organizationName: 'Acme',
invoiceAmountUsd: 1_200,
billingInterval: 'year',
reportingPeriodAnchorDate: '2026-08-01',
workspaceIds: ['workspace-1'],
invitations: [],
seats: 10_001,
})
).rejects.toThrow('none were omitted')
})
it('returns a product error for an invalid reporting anchor', async () => {
queueTableRows(schemaMock.user, [{ id: 'owner-1', name: 'Owner', email: 'owner@example.com' }])
queueTableRows(schemaMock.member, [])
@@ -259,6 +342,37 @@ function context() {
}
describe('Enterprise issuance serialization decisions', () => {
it('reserves seats only for invitations that do not already occupy or reserve one', () => {
expect(
computeEnterpriseIssuanceRequiredSeats({
memberSeats: 4,
pendingSeats: 2,
invitationEmails: ['member@example.com', 'pending@example.com', 'new@example.com'],
existingMemberEmails: new Set(['member@example.com']),
pendingInvitationEmails: new Set(['pending@example.com']),
})
).toBe(7)
})
it('includes distinct pending internal invitees carried by the workspace sweep', () => {
expect(
computeEnterpriseIssuanceRequiredSeats({
memberSeats: 1,
pendingSeats: 1,
invitationEmails: ['explicit@example.com', 'overlap@example.com'],
migratedInvitationEmails: [
'moved@example.com',
'moved@example.com',
'overlap@example.com',
'member@example.com',
'pending@example.com',
],
existingMemberEmails: new Set(['member@example.com']),
pendingInvitationEmails: new Set(['pending@example.com']),
})
).toBe(5)
})
it('includes the configured or invoice-defaulted usage limit in the request key', () => {
const input = {
ownerUserId: 'owner-1',
@@ -275,7 +389,7 @@ describe('Enterprise issuance serialization decisions', () => {
}
expect(buildEnterpriseProvisioningRequestKey(input, 'org-1', normalizedTerms)).toBe(
'enterprise-v5:owner-1:org-1:12500:year:2026-08-01::24000:12:concurrency=default:workflow-timeout=default:collection=active'
'enterprise-v6:owner-1:org-1:12500:year:2026-08-01:::24000:12:concurrency=default:workflow-timeout=default:collection=active'
)
expect(
buildEnterpriseProvisioningRequestKey(
@@ -284,7 +398,7 @@ describe('Enterprise issuance serialization decisions', () => {
normalizedTerms
)
).toBe(
'enterprise-v5:owner-1:org-1:12500:year:2026-08-01::24000:12:concurrency=1250:workflow-timeout=default:collection=active'
'enterprise-v6:owner-1:org-1:12500:year:2026-08-01:::24000:12:concurrency=1250:workflow-timeout=default:collection=active'
)
expect(
buildEnterpriseProvisioningRequestKey(
@@ -293,7 +407,7 @@ describe('Enterprise issuance serialization decisions', () => {
normalizedTerms
)
).toBe(
'enterprise-v5:owner-1:org-1:12500:year:2026-08-01::24000:12:concurrency=default:workflow-timeout=default:collection=paused'
'enterprise-v6:owner-1:org-1:12500:year:2026-08-01:::24000:12:concurrency=default:workflow-timeout=default:collection=paused'
)
expect(
buildEnterpriseProvisioningRequestKey(
@@ -302,10 +416,49 @@ describe('Enterprise issuance serialization decisions', () => {
normalizedTerms
)
).toBe(
'enterprise-v5:owner-1:org-1:12500:year:2026-08-01::25000:12:concurrency=default:workflow-timeout=default:collection=active'
'enterprise-v6:owner-1:org-1:12500:year:2026-08-01:::25000:12:concurrency=default:workflow-timeout=default:collection=active'
)
})
it('includes normalized creation-time invitations in the idempotency key', () => {
const normalizedTerms = {
billingInterval: 'year' as const,
reportingPeriodAnchorDate: '2026-08-01',
}
const base = {
ownerUserId: 'owner-1',
invoiceAmountUsd: 125,
seats: 12,
requestedByEmail: 'admin@sim.ai',
requestedByUserId: 'admin-1',
}
const first = buildEnterpriseProvisioningRequestKey(
{
...base,
invitations: [
{ email: 'B@Example.com', role: 'member' as const, permission: 'write' as const },
{ email: 'a@example.com', role: 'admin' as const, permission: 'admin' as const },
],
},
'org-1',
normalizedTerms
)
const reordered = buildEnterpriseProvisioningRequestKey(
{
...base,
invitations: [
{ email: 'a@example.com', role: 'admin' as const, permission: 'admin' as const },
{ email: 'b@example.com', role: 'member' as const, permission: 'write' as const },
],
},
'org-1',
normalizedTerms
)
expect(first).toBe(reordered)
expect(first).toContain('a@example.com,admin,admin;b@example.com,member,write')
})
it('keeps concurrency and workflow timeout in distinct request-key slots', () => {
const input = {
ownerUserId: 'owner-1',
@@ -464,6 +617,41 @@ describe('Enterprise workspace-move progress', () => {
})
})
it('surfaces correlated follow-up completion and dead-letter totals', async () => {
const payload = operationPayload()
const now = new Date('2026-08-13T00:00:00.000Z')
queueTableRows(schemaMock.outboxEvent, [
{
id: 'operation-1',
eventType: 'stripe.provision-enterprise',
status: 'completed',
payload,
attempts: 0,
maxAttempts: 5,
availableAt: now,
lockedAt: null,
processedAt: now,
lastError: null,
createdAt: now,
},
])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [
{ operationId: 'operation-1', selected: 3, completed: 1, failed: 1 },
])
const provisionings = await getLatestEnterpriseProvisionings(['org-1'])
expect(provisionings.get('org-1')?.followUpJobs).toEqual({
selected: 3,
completed: 1,
pending: 1,
failedCount: 1,
failed: [],
})
})
it('rejects provisioning lookups larger than one admin page', async () => {
await expect(
getLatestEnterpriseProvisionings(Array.from({ length: 251 }, (_, index) => `org-${index}`))
@@ -517,6 +705,262 @@ describe('Enterprise member reconciliation', () => {
})
})
describe('Enterprise creation invitations', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mocks.sendInvitationEmail.mockResolvedValue({ success: true })
mocks.createWorkspaceInvitation.mockResolvedValue({ id: 'new-invitation' })
mocks.prepareWorkspaceInvitationContext.mockResolvedValue({
inviterId: 'owner-1',
inviterName: 'Owner',
inviterEmail: 'owner@example.com',
organizationId: 'org-1',
targets: [
{
workspaceId: 'workspace-1',
workspaceDetails: { name: 'Workspace 1' },
},
],
})
})
it('waits without consuming attempts until every selected workspace move completes', async () => {
const payload = operationPayload({
request: {
...operationPayload().request,
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
},
applicationResult: {
appliedAt: '2026-08-13T00:00:00.000Z',
subscriptionId: 'sub-1',
},
})
queueTableRows(schemaMock.outboxEvent, [{ eventType: 'stripe.provision-enterprise', payload }])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [{ status: 'pending' }])
await expect(
inviteEnterprisePeople(
{
provisioningOperationId: 'operation-1',
organizationId: 'org-1',
ownerUserId: 'owner-1',
email: 'new@example.com',
role: 'member',
permission: 'write',
sequence: 0,
},
{
eventId: 'invite-1',
eventType: 'enterprise.invite-people',
attempts: 0,
checkpointPayload: vi.fn(),
}
)
).resolves.toEqual({
outcome: 'deferred',
reason: 'Waiting for the Enterprise workspace sweep before sending invitations',
consumeAttempt: false,
})
expect(mocks.prepareWorkspaceInvitationContext).not.toHaveBeenCalled()
expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled()
})
it('resends an exact pending invitation instead of treating its row as delivered', async () => {
const payload = operationPayload({
request: {
...operationPayload().request,
requestedByName: 'Platform Admin',
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
},
applicationResult: {
appliedAt: '2026-08-13T00:00:00.000Z',
subscriptionId: 'sub-1',
},
})
queueTableRows(schemaMock.outboxEvent, [{ eventType: 'stripe.provision-enterprise', payload }])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.invitation, [
{
id: 'pending-invitation',
token: 'pending-token',
role: 'member',
membershipIntent: 'internal',
workspaceId: 'workspace-1',
permission: 'write',
},
])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.user, [{ id: 'owner-1', name: 'Owner', email: 'owner@example.com' }])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.invitation, [
{
id: 'pending-invitation',
token: 'pending-token',
role: 'member',
membershipIntent: 'internal',
workspaceId: 'workspace-1',
permission: 'write',
},
])
const checkpointPayload = vi.fn()
await expect(
inviteEnterprisePeople(
{
provisioningOperationId: 'operation-1',
organizationId: 'org-1',
ownerUserId: 'owner-1',
email: 'new@example.com',
role: 'member',
permission: 'write',
sequence: 0,
},
{
eventId: 'invite-1',
eventType: 'enterprise.invite-people',
attempts: 1,
checkpointPayload,
}
)
).resolves.toBeUndefined()
expect(mocks.sendInvitationEmail).toHaveBeenCalledWith(
expect.objectContaining({
invitationId: 'pending-invitation',
token: 'pending-token',
email: 'new@example.com',
})
)
expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled()
expect(checkpointPayload).toHaveBeenCalledWith({
delivery: {
completedAt: expect.any(String),
resultId: 'pending-invitation',
outcome: 'sent',
},
})
})
it('refuses to complete over a weaker pending workspace grant', async () => {
const payload = operationPayload({
request: {
...operationPayload().request,
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
},
applicationResult: {
appliedAt: '2026-08-13T00:00:00.000Z',
subscriptionId: 'sub-1',
},
})
queueTableRows(schemaMock.outboxEvent, [{ eventType: 'stripe.provision-enterprise', payload }])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.invitation, [
{
id: 'pending-invitation',
token: 'pending-token',
role: 'member',
membershipIntent: 'internal',
workspaceId: 'workspace-1',
permission: 'read',
},
])
const checkpointPayload = vi.fn()
await expect(
inviteEnterprisePeople(
{
provisioningOperationId: 'operation-1',
organizationId: 'org-1',
ownerUserId: 'owner-1',
email: 'new@example.com',
role: 'member',
permission: 'write',
sequence: 0,
},
{
eventId: 'invite-1',
eventType: 'enterprise.invite-people',
attempts: 1,
checkpointPayload,
}
)
).rejects.toThrow('weaker pending grant')
expect(mocks.sendInvitationEmail).not.toHaveBeenCalled()
expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled()
expect(checkpointPayload).not.toHaveBeenCalled()
})
it('resends a stronger pending grant without downgrading it', async () => {
const payload = operationPayload({
request: {
...operationPayload().request,
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
},
applicationResult: {
appliedAt: '2026-08-13T00:00:00.000Z',
subscriptionId: 'sub-1',
},
})
const pendingGrant = {
id: 'pending-invitation',
token: 'pending-token',
role: 'member',
membershipIntent: 'internal',
workspaceId: 'workspace-1',
permission: 'admin',
}
queueTableRows(schemaMock.outboxEvent, [{ eventType: 'stripe.provision-enterprise', payload }])
queueTableRows(schemaMock.outboxEvent, [])
queueTableRows(schemaMock.outboxEvent, [{ status: 'completed' }])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.invitation, [pendingGrant])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.user, [{ id: 'owner-1', name: 'Owner', email: 'owner@example.com' }])
queueTableRows(schemaMock.user, [])
queueTableRows(schemaMock.invitation, [pendingGrant])
const checkpointPayload = vi.fn()
await inviteEnterprisePeople(
{
provisioningOperationId: 'operation-1',
organizationId: 'org-1',
ownerUserId: 'owner-1',
email: 'new@example.com',
role: 'member',
permission: 'write',
sequence: 0,
},
{
eventId: 'invite-1',
eventType: 'enterprise.invite-people',
attempts: 1,
checkpointPayload,
}
)
expect(mocks.sendInvitationEmail).toHaveBeenCalled()
expect(mocks.createWorkspaceInvitation).not.toHaveBeenCalled()
expect(checkpointPayload).toHaveBeenCalledWith({
delivery: {
completedAt: expect.any(String),
resultId: 'pending-invitation',
outcome: 'sent',
},
})
})
})
describe('Enterprise issuance outbox handler', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -741,7 +1185,18 @@ describe('Enterprise issuance outbox handler', () => {
arrangeWorkerReads([], [], 13)
await expect(provisionEnterpriseInStripe(operationPayload(), context())).rejects.toThrow(
'seat capacity is below current internal membership'
'seat capacity is below current occupied or reserved seats'
)
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled()
})
it('rechecks pending seat reservations immediately before Stripe create', async () => {
arrangeWorkerReads([], [], 1)
queueTableRows(schemaMock.invitation, [{ count: 12 }])
await expect(provisionEnterpriseInStripe(operationPayload(), context())).rejects.toThrow(
'seat capacity is below current occupied or reserved seats'
)
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled()
@@ -837,6 +1292,37 @@ describe('Enterprise metadata outbox handler', () => {
)
})
it('does not send a seat decrease below current pending reservations to Stripe', async () => {
const payload = {
subscriptionId: 'local-sub-1',
revision: 5,
deliveryRevision: 0,
metadata: {
plan: 'enterprise',
referenceId: 'org-1',
seats: 15,
},
}
queueTableRows(schemaMock.subscription, [
{ stripeSubscriptionId: 'sub_1', referenceId: 'org-1', metadata: {} },
])
queueTableRows(schemaMock.subscription, [{ metadata: {} }])
queueTableRows(schemaMock.outboxEvent, [{ id: 'metadata-event-capacity', payload }])
queueTableRows(schemaMock.member, [{ value: 10 }])
queueTableRows(schemaMock.invitation, [{ count: 6 }])
await expect(
syncEnterpriseMetadataInStripe(payload, {
eventId: 'metadata-event-capacity',
eventType: 'stripe.sync-enterprise-metadata',
attempts: 0,
checkpointPayload: vi.fn(),
})
).rejects.toThrow('seat intent is below current occupied or reserved seats')
expect(mocks.subscriptionsUpdate).not.toHaveBeenCalled()
})
it('unsets nullable metadata overrides in Stripe', async () => {
const payload = {
subscriptionId: 'local-sub-1',
File diff suppressed because it is too large Load Diff
@@ -808,7 +808,8 @@ interface PaidOrgJoinBillingActions {
async function applyPaidOrgJoinBillingTx(
tx: DbOrTx,
userId: string,
organizationId: string
organizationId: string,
options: { sourceOperationId?: string } = {}
): Promise<PaidOrgJoinBillingActions> {
const actions: PaidOrgJoinBillingActions = {
proUsageSnapshotted: false,
@@ -867,6 +868,7 @@ async function applyPaidOrgJoinBillingTx(
stripeSubscriptionId: personalPro.stripeSubscriptionId,
subscriptionId: personalPro.id,
reason: 'joined-paid-org',
...(options.sourceOperationId ? { sourceOperationId: options.sourceOperationId } : {}),
})
}
@@ -895,7 +897,8 @@ async function applyPaidOrgJoinBillingTx(
export async function reapplyPaidOrgJoinBillingForExistingMemberTx(
tx: DbOrTx,
userId: string,
organizationId: string
organizationId: string,
options: { sourceOperationId?: string } = {}
): Promise<PaidOrgJoinBillingActions> {
await acquireUserBillingIdentityLock(tx, userId)
const [orgSub] = await tx
@@ -913,7 +916,7 @@ export async function reapplyPaidOrgJoinBillingForExistingMemberTx(
return { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false }
}
return applyPaidOrgJoinBillingTx(tx, userId, organizationId)
return applyPaidOrgJoinBillingTx(tx, userId, organizationId, options)
}
type InvitationRemovalScope = 'all' | 'external'
@@ -33,6 +33,7 @@ interface OrganizationSeatInfo {
interface ValidateSeatOptions {
excludePendingInvitationId?: string
executor?: DbOrTx
}
/**
@@ -152,10 +153,14 @@ export async function validateSeatAvailability(
options: ValidateSeatOptions = {}
): Promise<SeatValidationResult> {
try {
const executor = options.executor ?? db
if (!isBillingEnabled) {
const [memberCount, pendingSeats] = await Promise.all([
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
countPendingSeatInvitations(organizationId),
executor
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId)),
countPendingSeatInvitations(organizationId, executor),
])
return {
canInvite: true,
@@ -165,7 +170,7 @@ export async function validateSeatAvailability(
}
}
const subscription = await getOrganizationSubscription(organizationId)
const subscription = await getOrganizationSubscription(organizationId, { executor })
if (!subscription) {
return {
@@ -188,9 +193,12 @@ export async function validateSeatAvailability(
}
const [memberCount, pendingSeats, maxSeats] = await Promise.all([
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
countPendingSeatInvitations(organizationId, db, options.excludePendingInvitationId),
resolveSeatCapacity(subscription),
executor
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId)),
countPendingSeatInvitations(organizationId, executor, options.excludePendingInvitationId),
resolveSeatCapacity(subscription, executor),
])
const {
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
patchOutboxEventPayload: vi.fn(),
enqueueOutboxEvent: vi.fn(),
enqueueOutboxEvents: vi.fn(),
getEnterpriseIssuanceSeatRequirement: vi.fn(),
reapplyPaidOrgJoinBillingForExistingMemberTx: vi.fn(),
}))
@@ -37,6 +38,10 @@ vi.mock('@/lib/billing/organizations/membership', () => ({
reapplyPaidOrgJoinBillingForExistingMemberTx: mocks.reapplyPaidOrgJoinBillingForExistingMemberTx,
}))
vi.mock('@/lib/billing/enterprise-provisioning', () => ({
getEnterpriseIssuanceSeatRequirement: mocks.getEnterpriseIssuanceSeatRequirement,
}))
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: () => ({
subscriptions: { retrieve: mocks.subscriptionsRetrieve },
@@ -76,7 +81,17 @@ import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enter
const ENTERPRISE_PROVISION_EVENT_TYPE = 'stripe.provision-enterprise'
function operationPayload(
options: { applied?: boolean; pausePaymentCollection?: boolean; workspaceIds?: string[] } = {}
options: {
applied?: boolean
pausePaymentCollection?: boolean
workspaceIds?: string[]
invitations?: Array<{
email: string
role: 'admin' | 'member'
permission: 'admin' | 'write' | 'read'
}>
logoutOwnerOnApply?: boolean
} = {}
) {
return {
version: 1 as const,
@@ -91,6 +106,8 @@ function operationPayload(
seats: 12,
concurrencyLimit: 1250,
workspaceIds: options.workspaceIds ?? [],
invitations: options.invitations ?? [],
logoutOwnerOnApply: options.logoutOwnerOnApply ?? false,
pausePaymentCollection: options.pausePaymentCollection ?? false,
},
retryRevision: 0,
@@ -111,12 +128,13 @@ function stripeSubscription(options: {
paused?: boolean
configOperationId?: string
seats?: number
status?: Stripe.Subscription.Status
}): Stripe.Subscription {
const seats = options.seats ?? 12
return {
id: 'sub_1',
customer: 'cus_1',
status: 'active',
status: options.status ?? 'active',
collection_method: 'send_invoice',
days_until_due: 30,
pause_collection: options.paused ? { behavior: 'keep_as_draft', resumes_at: null } : null,
@@ -195,6 +213,7 @@ describe('Enterprise webhook issuance correlation', () => {
mocks.reapplyPaidOrgJoinBillingForExistingMemberTx.mockResolvedValue(undefined)
mocks.enqueueOutboxEvent.mockResolvedValue('move-event')
mocks.enqueueOutboxEvents.mockResolvedValue(['move-event'])
mocks.getEnterpriseIssuanceSeatRequirement.mockResolvedValue({ requiredSeats: 1 })
})
afterAll(() => {
@@ -275,6 +294,89 @@ describe('Enterprise webhook issuance correlation', () => {
)
})
it('queues creation invitations and revokes the owner session only after verified apply', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
queueSuccessfulExistingSubscriptionReconciliation({
operation: operationPayload({
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
logoutOwnerOnApply: true,
}),
})
await expect(
handleManualEnterpriseSubscription(eventFor(subscription))
).resolves.toBeUndefined()
expect(mocks.enqueueOutboxEvents).toHaveBeenCalledWith(
expect.anything(),
'enterprise.invite-people',
[
expect.objectContaining({
email: 'new@example.com',
organizationId: 'org-1',
sequence: 0,
}),
]
)
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.session)
expect(dbChainMockFns.set.mock.calls).toContainEqual([
expect.objectContaining({ securityPolicyVersion: expect.anything() }),
])
})
it('does not apply issuance children or logout until Stripe reports an entitled status', async () => {
const subscription = stripeSubscription({
operationId: 'operation-1',
paused: false,
status: 'incomplete',
})
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
mocks.getEnterpriseIssuanceSeatRequirement.mockResolvedValue({ requiredSeats: 99 })
queueSuccessfulExistingSubscriptionReconciliation({
operation: operationPayload({
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
logoutOwnerOnApply: true,
}),
})
await expect(
handleManualEnterpriseSubscription(eventFor(subscription))
).resolves.toBeUndefined()
expect(mocks.enqueueOutboxEvents).not.toHaveBeenCalled()
expect(mocks.enqueueOutboxEvent).not.toHaveBeenCalled()
expect(mocks.patchOutboxEventPayload).not.toHaveBeenCalled()
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
expect(dbChainMockFns.set.mock.calls).not.toContainEqual([
expect.objectContaining({ securityPolicyVersion: expect.anything() }),
])
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ status: 'incomplete' })
)
})
it('refuses an entitled issuance when live reservations outgrow its Stripe seat capacity', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', seats: 12 })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
mocks.getEnterpriseIssuanceSeatRequirement.mockResolvedValue({ requiredSeats: 13 })
queueSuccessfulExistingSubscriptionReconciliation({
operation: operationPayload({
workspaceIds: ['workspace-1'],
invitations: [{ email: 'new@example.com', role: 'member', permission: 'write' }],
}),
})
await expect(handleManualEnterpriseSubscription(eventFor(subscription))).rejects.toThrow(
'below 13 occupied or reserved seats'
)
expect(mocks.enqueueOutboxEvents).not.toHaveBeenCalled()
expect(mocks.patchOutboxEventPayload).not.toHaveBeenCalled()
})
it('allows later Stripe metadata edits after the issuance was already applied', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
+67 -11
View File
@@ -1,14 +1,19 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, outboxEvent, subscription, user } from '@sim/db/schema'
import { organization, outboxEvent, session, subscription, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { and, count, eq, inArray, sql } from 'drizzle-orm'
import { and, eq, inArray, sql } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, renderEnterpriseSubscriptionEmail } from '@/components/emails'
import {
invalidateMembershipCache,
invalidateSecurityPolicyVersionCache,
} from '@/lib/auth/security-policy'
import { deriveEnterpriseCreditLimits } from '@/lib/billing/enterprise-credit-limits'
import {
ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE,
ENTERPRISE_MEMBER_RECONCILIATION_EVENT_TYPE,
ENTERPRISE_METADATA_SYNC_EVENT_TYPE,
ENTERPRISE_PROVISION_EVENT_TYPE,
@@ -20,6 +25,7 @@ import {
enterpriseOperationMatchesStripeSubscription,
parseEnterpriseProvisionPayload,
} from '@/lib/billing/enterprise-outbox'
import { getEnterpriseIssuanceSeatRequirement } from '@/lib/billing/enterprise-provisioning'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import {
@@ -164,6 +170,7 @@ async function reconcileManualEnterpriseSubscription(
billingInterval: referenceItem?.price?.recurring?.interval ?? null,
metadata: metadata as Record<string, unknown>,
}
const isEntitled = hasPaidSubscriptionStatus(subscriptionRow.status)
const coreResult = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, referenceId)
@@ -219,7 +226,7 @@ async function reconcileManualEnterpriseSubscription(
)
if (validCorrelation && operationPayload) {
correlatedOperation = operationPayload
operationNewlyApplied = !operationPayload.applicationResult
operationNewlyApplied = isEntitled && !operationPayload.applicationResult
} else if (
operationRow?.eventType === ENTERPRISE_PROVISION_EVENT_TYPE &&
(!operationPayload || !operationPayload.applicationResult)
@@ -255,13 +262,21 @@ async function reconcileManualEnterpriseSubscription(
}
}
const [currentMemberCount] = await tx
.select({ value: count() })
.from(member)
.where(eq(member.organizationId, referenceId))
if (seats < (currentMemberCount?.value ?? 0)) {
const seatRequirement = await getEnterpriseIssuanceSeatRequirement({
executor: tx,
organizationId: referenceId,
workspaceIds:
operationNewlyApplied && correlatedOperation
? correlatedOperation.request.workspaceIds
: [],
invitationEmails:
operationNewlyApplied && correlatedOperation
? correlatedOperation.request.invitations.map((invite) => invite.email)
: [],
})
if (isEntitled && seats < seatRequirement.requiredSeats) {
throw new Error(
`Enterprise seat capacity ${seats} is below current internal membership ${currentMemberCount?.value ?? 0}`
`Enterprise seat capacity ${seats} is below ${seatRequirement.requiredSeats} occupied or reserved seats`
)
}
@@ -386,14 +401,44 @@ async function reconcileManualEnterpriseSubscription(
workspaceId,
destinationOrganizationId: referenceId,
expectedOwnerId: correlatedOperation.request.ownerUserId,
adminUserId: correlatedOperation.request.requestedByUserId,
adminName: correlatedOperation.request.requestedByName,
adminEmail: correlatedOperation.request.requestedByEmail,
sequence,
}))
)
if (correlatedOperation.request.invitations.length > 0) {
await enqueueOutboxEvents(
tx,
ENTERPRISE_INVITE_PEOPLE_EVENT_TYPE,
correlatedOperation.request.invitations.map((invite, sequence) => ({
provisioningOperationId: operationId,
organizationId: referenceId,
ownerUserId: correlatedOperation.request.ownerUserId,
sequence,
...invite,
}))
)
}
if (correlatedOperation.request.logoutOwnerOnApply) {
await tx
.delete(session)
.where(
and(
eq(session.userId, correlatedOperation.request.ownerUserId),
sql`${session.impersonatedBy} IS NULL`
)
)
await tx
.update(organization)
.set({
securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1`,
})
.where(eq(organization.id, referenceId))
}
}
const wasEntitled = hasPaidSubscriptionStatus(existing?.status)
const isEntitled = hasPaidSubscriptionStatus(subscriptionRow.status)
const triggerRestoredEntitlement = Boolean(
trigger.previousStatus && !hasPaidSubscriptionStatus(trigger.previousStatus)
)
@@ -407,11 +452,13 @@ async function reconcileManualEnterpriseSubscription(
) {
await enqueueOutboxEvent(tx, ENTERPRISE_MEMBER_RECONCILIATION_EVENT_TYPE, {
organizationId: referenceId,
provisioningOperationId:
operationNewlyApplied && typeof operationId === 'string' ? operationId : null,
afterUserId: null,
})
}
if (correlatedOperation && typeof operationId === 'string') {
if (isEntitled && correlatedOperation && typeof operationId === 'string') {
const operationPatched = await patchOutboxEventPayload(tx, operationId, {
applicationResult: {
appliedAt: correlatedOperation.applicationResult?.appliedAt ?? new Date().toISOString(),
@@ -434,6 +481,10 @@ async function reconcileManualEnterpriseSubscription(
operationNewlyApplied && correlatedOperation
? correlatedOperation.request.workspaceIds.length
: 0,
loggedOutOwnerId:
operationNewlyApplied && correlatedOperation?.request.logoutOwnerOnApply
? correlatedOperation.request.ownerUserId
: null,
...creditLimits,
}
})
@@ -446,10 +497,15 @@ async function reconcileManualEnterpriseSubscription(
hasCorrelatedOperation,
subscriptionNewlyInserted,
queuedWorkspaceCount,
loggedOutOwnerId,
configuredUsageLimitCredits,
prepaidCredits,
effectiveUsageLimitCredits,
} = coreResult
if (loggedOutOwnerId) {
invalidateMembershipCache(loggedOutOwnerId)
invalidateSecurityPolicyVersionCache(referenceId)
}
const shouldAnnounce = hasCorrelatedOperation ? operationNewlyApplied : subscriptionNewlyInserted
logger.info('[subscription.created] Upserted enterprise subscription', {
@@ -4,19 +4,28 @@
import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetPlanByName, mockResolveDefaultPaymentMethod, stripeMock } = vi.hoisted(() => {
const stripeMock = {
subscriptions: {
retrieve: vi.fn(),
update: vi.fn(),
},
}
return {
mockGetPlanByName: vi.fn(),
mockResolveDefaultPaymentMethod: vi.fn(),
stripeMock,
}
})
const { mockGetPlanByName, mockRecordAuditOnce, mockResolveDefaultPaymentMethod, stripeMock } =
vi.hoisted(() => {
const stripeMock = {
subscriptions: {
cancel: vi.fn(),
retrieve: vi.fn(),
update: vi.fn(),
},
}
return {
mockGetPlanByName: vi.fn(),
mockRecordAuditOnce: vi.fn(),
mockResolveDefaultPaymentMethod: vi.fn(),
stripeMock,
}
})
vi.mock('@sim/audit', () => ({
AuditAction: { SUBSCRIPTION_CANCELLED: 'subscription.cancelled' },
AuditResourceType: { SUBSCRIPTION: 'subscription' },
recordAuditOnce: mockRecordAuditOnce,
}))
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: () => stripeMock,
@@ -33,6 +42,8 @@ vi.mock('@/lib/billing/stripe-payment-method', () => ({
import { billingOutboxHandlers, OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
const seatSyncHandler = billingOutboxHandlers[OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]
const immediateCancellationHandler =
billingOutboxHandlers[OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY]
const ctx = {
eventId: 'evt-1',
@@ -202,3 +213,33 @@ describe('stripeSyncSubscriptionSeats outbox handler', () => {
expect(stripeMock.subscriptions.update).not.toHaveBeenCalled()
})
})
describe('stripeCancelSubscriptionImmediately outbox handler', () => {
beforeEach(() => {
vi.clearAllMocks()
stripeMock.subscriptions.cancel.mockResolvedValue({ id: 'stripe_sub', status: 'canceled' })
})
it('uses the durable event id for Stripe idempotency and leaves Sim cleanup to the webhook', async () => {
await immediateCancellationHandler(
{
stripeSubscriptionId: 'stripe_sub',
subscriptionId: 'sub-1',
organizationId: 'org-1',
operationId: '67e55044-10b1-426f-9247-bb680e5fe0c8',
requestedBy: { id: 'admin-1', name: 'Admin', email: 'admin@sim.ai' },
},
{
eventId: 'cancel-event-1',
eventType: OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY,
attempts: 0,
}
)
expect(stripeMock.subscriptions.cancel).toHaveBeenCalledWith(
'stripe_sub',
{ prorate: true, invoice_now: true },
{ idempotencyKey: 'outbox:cancel-event-1' }
)
})
})
@@ -1,3 +1,4 @@
import { AuditAction, AuditResourceType, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import { member, subscription as subscriptionTable, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
@@ -21,6 +22,8 @@ export const OUTBOX_EVENT_TYPES = {
* enqueue this event after every DB change to `cancelAtPeriodEnd`.
*/
STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
/** Cancel in Stripe; the verified deletion webhook remains the only DB entitlement authority. */
STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY: 'stripe.cancel-subscription-immediately',
/**
* Sync a Team subscription's price and seat quantity from our DB to
* Stripe. The handler reads the current DB plan + seats at processing
@@ -40,6 +43,46 @@ interface StripeSyncCancelAtPeriodEndPayload {
subscriptionId: string
/** Optional: reason this was enqueued — e.g. 'member-joined-paid-org'. */
reason?: string
/** Correlates Enterprise-issuance follow-up work for Admin progress/retry. */
sourceOperationId?: string
operationId?: string
organizationId?: string
requestedBy?: { id: string | null; name: string; email: string | null }
}
interface StripeCancelSubscriptionImmediatelyPayload {
stripeSubscriptionId: string
subscriptionId: string
organizationId: string
operationId: string
reason?: string
requestedBy: { id: string | null; name: string; email: string | null }
}
async function recordAdminCancellationAudit(params: {
operationId?: string
organizationId?: string
subscriptionId: string
requestedBy?: { id: string | null; name: string; email: string | null }
timing: 'period_end' | 'immediate'
reason?: string
}) {
if (!params.operationId || !params.organizationId || !params.requestedBy) return
await recordAuditOnce(`${params.operationId}:cancellation-requested`, {
actorId: params.requestedBy.id,
actorName: params.requestedBy.name,
actorEmail: params.requestedBy.email,
action: AuditAction.SUBSCRIPTION_CANCELLED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: params.subscriptionId,
description: `Admin requested ${params.timing === 'period_end' ? 'period-end' : 'immediate'} organization subscription cancellation`,
metadata: {
organizationId: params.organizationId,
requestOperationId: params.operationId,
timing: params.timing,
reason: params.reason ?? null,
},
})
}
interface StripeSyncSubscriptionSeatsPayload {
@@ -86,6 +129,7 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<StripeSyncCancelAtPeriodEndPayl
payload,
ctx
) => {
await recordAdminCancellationAudit({ ...payload, timing: 'period_end' })
// Read the DB value at processing time (not at enqueue time). This
// makes the handler idempotent across racing enqueues: multiple
// events for the same subscription all push whatever the DB
@@ -119,6 +163,26 @@ const stripeSyncCancelAtPeriodEnd: OutboxHandler<StripeSyncCancelAtPeriodEndPayl
})
}
const stripeCancelSubscriptionImmediately: OutboxHandler<
StripeCancelSubscriptionImmediatelyPayload
> = async (payload, ctx) => {
await recordAdminCancellationAudit({ ...payload, timing: 'immediate' })
const stripe = requireStripeClient()
await stripe.subscriptions.cancel(
payload.stripeSubscriptionId,
{ prorate: true, invoice_now: true },
{ idempotencyKey: `outbox:${ctx.eventId}` }
)
logger.info('Cancelled subscription immediately in Stripe; awaiting verified webhook cleanup', {
eventId: ctx.eventId,
organizationId: payload.organizationId,
subscriptionId: payload.subscriptionId,
stripeSubscriptionId: payload.stripeSubscriptionId,
operationId: payload.operationId,
reason: payload.reason,
})
}
const stripeSyncSubscriptionSeats: OutboxHandler<StripeSyncSubscriptionSeatsPayload> = async (
payload,
ctx
@@ -396,6 +460,8 @@ const stripeSyncCustomerContact: OutboxHandler<StripeSyncCustomerContactPayload>
export const billingOutboxHandlers = {
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END]:
stripeSyncCancelAtPeriodEnd as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY]:
stripeCancelSubscriptionImmediately as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS]:
stripeSyncSubscriptionSeats as OutboxHandler<unknown>,
[OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE]:
+24
View File
@@ -29,6 +29,7 @@ import {
enqueueOrReschedulePendingOutboxEvent,
enqueueOutboxEvent,
enqueueOutboxEvents,
outboxPayloadHasSourceOperationId,
processOutboxEvents,
} from './service'
@@ -121,6 +122,29 @@ describe('enqueueOutboxEvent', () => {
})
})
describe('outbox parent-operation correlation', () => {
it('retains both scalar and coalesced parent operation identities', () => {
expect(
outboxPayloadHasSourceOperationId({ sourceOperationId: 'operation-1' }, 'operation-1')
).toBe(true)
expect(
outboxPayloadHasSourceOperationId(
{ sourceOperationIds: ['operation-1', 'operation-2'] },
'operation-1'
)
).toBe(true)
expect(
outboxPayloadHasSourceOperationId(
{ sourceOperationIds: ['operation-1', 'operation-2'] },
'operation-2'
)
).toBe(true)
expect(
outboxPayloadHasSourceOperationId({ sourceOperationIds: ['operation-2'] }, 'operation-1')
).toBe(false)
})
})
describe('enqueueOrReschedulePendingOutboxEvent', () => {
beforeEach(() => {
vi.clearAllMocks()
+52 -1
View File
@@ -112,6 +112,8 @@ export type OutboxHandler<T = unknown> = (
export type OutboxHandlerRegistry = Record<string, OutboxHandler>
export interface EnqueueOptions {
/** Caller-owned idempotency key. Defaults to a generated UUID. */
id?: string
/** Total attempts before the event moves to `dead_letter`. Default 10. */
maxAttempts?: number
/** Earliest time a worker may pick up this event. Default now. */
@@ -160,7 +162,7 @@ export async function enqueueOutboxEvent<T>(
payload: T,
options: EnqueueOptions = {}
): Promise<string> {
const id = generateId()
const id = options.id ?? generateId()
await executor.insert(outboxEvent).values({
id,
eventType,
@@ -293,6 +295,55 @@ export async function patchOutboxEventPayload(
return result.length > 0
}
/**
* Adds a durable parent-operation correlation to an outbox event without
* replacing correlations already attached by another coalesced mutation.
*/
export async function addOutboxEventSourceOperationId(
executor: Pick<typeof db, 'update'>,
eventId: string,
operationId: string
): Promise<boolean> {
const result = await executor
.update(outboxEvent)
.set({
payload: sql`jsonb_set(
coalesce(${outboxEvent.payload}::jsonb, '{}'::jsonb),
'{sourceOperationIds}',
case
when coalesce(${outboxEvent.payload}::jsonb -> 'sourceOperationIds', '[]'::jsonb)
@> jsonb_build_array(${operationId}::text)
then coalesce(${outboxEvent.payload}::jsonb -> 'sourceOperationIds', '[]'::jsonb)
else coalesce(${outboxEvent.payload}::jsonb -> 'sourceOperationIds', '[]'::jsonb)
|| jsonb_build_array(${operationId}::text)
end,
true
)::json`,
})
.where(eq(outboxEvent.id, eventId))
.returning({ id: outboxEvent.id })
return result.length > 0
}
/** Matches both ordinary single-parent events and coalesced multi-parent events. */
export function outboxEventHasSourceOperationId(operationId: string) {
return sql<boolean>`(
${outboxEvent.payload} ->> 'sourceOperationId' = ${operationId}
or coalesce(${outboxEvent.payload} -> 'sourceOperationIds', '[]'::jsonb)
@> jsonb_build_array(${operationId}::text)
)`
}
/** Runtime equivalent of `outboxEventHasSourceOperationId` for locked-row checks. */
export function outboxPayloadHasSourceOperationId(payload: unknown, operationId: string): boolean {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
const record = payload as Record<string, unknown>
return (
record.sourceOperationId === operationId ||
(Array.isArray(record.sourceOperationIds) && record.sourceOperationIds.includes(operationId))
)
}
/** Cap on how many dead-lettered rows a single reconciler scan materializes. */
const DEAD_LETTER_SCAN_LIMIT = 100
+1 -5
View File
@@ -50,11 +50,7 @@ import { getInvitePlanCategoryForUser } from '@/lib/workspaces/policy'
const logger = createLogger('InvitationCore')
export const INVITATION_EXPIRY_DAYS = 7
export function computeInvitationExpiry(daysFromNow = INVITATION_EXPIRY_DAYS): Date {
return new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
}
export { computeInvitationExpiry, INVITATION_EXPIRY_DAYS } from '@/lib/invitations/expiry'
export interface InvitationWithGrants {
id: string
+124 -1
View File
@@ -22,6 +22,7 @@ const {
mockSendWorkspaceAddedEmail,
mockCaptureServerEvent,
mockWorkspaceMemberAdded,
mockEnqueueOutboxEvent,
} = vi.hoisted(() => ({
mockAcquireInvitationMutationLocks: vi.fn(),
mockAcquireOrganizationUserMutationLocks: vi.fn(),
@@ -33,6 +34,7 @@ const {
mockSendWorkspaceAddedEmail: vi.fn(),
mockCaptureServerEvent: vi.fn(),
mockWorkspaceMemberAdded: vi.fn(),
mockEnqueueOutboxEvent: vi.fn(),
}))
vi.mock('@sim/audit', () => auditMock)
@@ -54,6 +56,10 @@ vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: { workspaceMemberAdded: mockWorkspaceMemberAdded },
}))
vi.mock('@/lib/core/outbox/service', () => ({
enqueueOutboxEvent: mockEnqueueOutboxEvent,
}))
vi.mock('@/lib/credentials/environment', () => ({
syncWorkspaceEnvCredentials: mockSyncWorkspaceEnvCredentials,
}))
@@ -72,7 +78,9 @@ vi.mock('@/lib/posthog/server', () => ({
}))
import {
DIRECT_GRANT_EMAIL_EVENT_TYPE,
DirectGrantContextChangedError,
directGrantOutboxHandlers,
grantWorkspaceAccessDirectly,
} from '@/lib/invitations/direct-grant'
@@ -128,7 +136,9 @@ describe('grantWorkspaceAccessDirectly', () => {
expect(mockWorkspaceMemberAdded).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: 'ws-1' })
)
expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith(
expect(mockEnqueueOutboxEvent).toHaveBeenCalledWith(
expect.anything(),
DIRECT_GRANT_EMAIL_EVENT_TYPE,
expect.objectContaining({ email: 'member@example.com', workspaceId: 'ws-1' })
)
expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan(
@@ -141,6 +151,95 @@ describe('grantWorkspaceAccessDirectly', () => {
expect(dbChainMockFns.from).toHaveBeenCalledWith(member)
})
it('delivers the transactionally enqueued notification through the outbox', async () => {
await directGrantOutboxHandlers[DIRECT_GRANT_EMAIL_EVENT_TYPE](
{
email: 'member@example.com',
inviterName: 'Owner',
workspaceId: 'ws-1',
workspaceName: 'Workspace 1',
},
{
eventId: 'email-1',
eventType: DIRECT_GRANT_EMAIL_EVENT_TYPE,
attempts: 0,
maxAttempts: 10,
signal: new AbortController().signal,
checkpointPayload: vi.fn(),
}
)
expect(mockSendWorkspaceAddedEmail).toHaveBeenCalledWith({
email: 'member@example.com',
inviterName: 'Owner',
workspaceId: 'ws-1',
workspaceName: 'Workspace 1',
})
})
it('retries provider-declined notification delivery instead of dropping it', async () => {
mockSendWorkspaceAddedEmail.mockResolvedValueOnce({
success: false,
error: 'Provider unavailable',
})
await expect(
directGrantOutboxHandlers[DIRECT_GRANT_EMAIL_EVENT_TYPE](
{
email: 'member@example.com',
inviterName: 'Owner',
workspaceId: 'ws-1',
workspaceName: 'Workspace 1',
},
{
eventId: 'email-1',
eventType: DIRECT_GRANT_EMAIL_EVENT_TYPE,
attempts: 0,
maxAttempts: 10,
signal: new AbortController().signal,
checkpointPayload: vi.fn(),
}
)
).rejects.toThrow('Provider unavailable')
})
it('rejects malformed durable notification payloads instead of dropping fields', async () => {
await expect(
directGrantOutboxHandlers[DIRECT_GRANT_EMAIL_EVENT_TYPE](
{
email: 'member@example.com',
inviterName: 'Owner',
workspaceId: 'ws-1',
},
{
eventId: 'email-1',
eventType: DIRECT_GRANT_EMAIL_EVENT_TYPE,
attempts: 0,
maxAttempts: 10,
signal: new AbortController().signal,
checkpointPayload: vi.fn(),
}
)
).rejects.toThrow('Invalid workspace-added email payload')
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('preserves an actor-less platform admin in audit instead of substituting the owner', async () => {
await grantWorkspaceAccessDirectly({
...baseInput,
auditActor: { id: null, name: 'Admin Panel', email: null },
})
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({
actorId: null,
actorName: 'Admin Panel',
actorEmail: null,
})
)
})
it('reports unchanged (no audit/email) when a concurrent insert wins the race', async () => {
dbChainMockFns.returning.mockResolvedValueOnce([])
@@ -165,6 +264,30 @@ describe('grantWorkspaceAccessDirectly', () => {
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('can explicitly ensure a minimum permission for provisioning reconciliation', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'read' }])
const result = await grantWorkspaceAccessDirectly({
...baseInput,
permission: 'write',
existingPermissionPolicy: 'ensure-at-least',
})
expect(result).toEqual({
outcome: 'updated',
previousPermission: 'read',
permission: 'write',
})
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ permissionType: 'write' })
)
expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'member.role_changed', resourceId: 'ws-1' })
)
expect(mockWorkspaceMemberAdded).not.toHaveBeenCalled()
expect(mockSendWorkspaceAddedEmail).not.toHaveBeenCalled()
})
it('no-ops when the user already has access', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'perm-1', permissionType: 'admin' }])
+133 -58
View File
@@ -1,4 +1,4 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import {
invitation,
@@ -8,7 +8,9 @@ import {
workspaceEnvironment,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { permissionSatisfies } from '@sim/platform-authz/workspace'
import { generateId } from '@sim/utils/id'
import { isRecordLike } from '@sim/utils/object'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
@@ -16,6 +18,7 @@ import {
acquireOrganizationUserMutationLocks,
getUserOrganization,
} from '@/lib/billing/organizations/membership'
import { enqueueOutboxEvent, type OutboxHandler } from '@/lib/core/outbox/service'
import { PlatformEvents } from '@/lib/core/telemetry'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import type { DbOrTx } from '@/lib/db/types'
@@ -31,8 +34,19 @@ import {
const logger = createLogger('InvitationDirectGrant')
export const DIRECT_GRANT_EMAIL_EVENT_TYPE = 'invitation.send-workspace-added'
export interface DirectGrantEmailPayload {
email: string
inviterName: string
workspaceId: string
workspaceName: string
sourceOperationId?: string
}
export type DirectGrantOutcome =
| { outcome: 'added'; permission: PermissionType }
| { outcome: 'updated'; permission: PermissionType; previousPermission: PermissionType }
| { outcome: 'unchanged'; permission: PermissionType }
export class DirectGrantContextChangedError extends Error {
@@ -62,9 +76,17 @@ export interface GrantWorkspaceAccessDirectlyInput {
actorId: string
actorName: string
actorEmail?: string | null
/** Audit attribution may differ from the authorized product actor for admin tooling. */
auditActor?: { id: string | null; name: string; email: string | null }
request?: NextRequest
/** Send the lightweight "you've been added" email. Defaults to true. */
notify?: boolean
/** Ordinary invites preserve access; provisioning may explicitly ensure the requested minimum. */
existingPermissionPolicy?: 'preserve' | 'ensure-at-least'
/** Correlates durable notification delivery with a parent Admin operation. */
sourceOperationId?: string
/** Makes the semantic audit recoverable when the caller itself is durable. */
auditOperationId?: string
}
async function getPendingWorkspaceInvitationIds(
@@ -89,9 +111,9 @@ async function getPendingWorkspaceInvitationIds(
/**
* Grants a user workspace access immediately, without an invitation or
* acceptance step. Intended for users who already belong to the workspace's
* organization and are not yet members of the workspace. Idempotent: when a
* permission already exists it is left untouched (no-op) invites never modify
* or upgrade an existing member's permission.
* organization and are not yet members of the workspace. Idempotent by default:
* existing permissions are preserved. Trusted provisioning/Admin operations may
* explicitly ensure a minimum permission without ever downgrading stronger access.
*/
export async function grantWorkspaceAccessDirectly(
input: GrantWorkspaceAccessDirectlyInput
@@ -190,9 +212,22 @@ export async function grantWorkspaceAccessDirectly(
let outcome: DirectGrantOutcome
if (existing) {
outcome = {
outcome: 'unchanged',
permission: existing.permissionType as PermissionType,
const existingPermission = existing.permissionType as PermissionType
if (
input.existingPermissionPolicy === 'ensure-at-least' &&
!permissionSatisfies(existingPermission, input.permission)
) {
await tx
.update(permissions)
.set({ permissionType: input.permission, updatedAt: new Date() })
.where(eq(permissions.id, existing.id))
outcome = {
outcome: 'updated',
permission: input.permission,
previousPermission: existingPermission,
}
} else {
outcome = { outcome: 'unchanged', permission: existingPermission }
}
} else {
const inserted = await tx
@@ -224,6 +259,16 @@ export async function grantWorkspaceAccessDirectly(
}
}
if (outcome.outcome === 'added' && (input.notify ?? true)) {
await enqueueOutboxEvent(tx, DIRECT_GRANT_EMAIL_EVENT_TYPE, {
email: normalizedEmail,
inviterName: input.actorName,
workspaceId: input.workspaceId,
workspaceName: input.workspaceName,
...(input.sourceOperationId ? { sourceOperationId: input.sourceOperationId } : {}),
})
}
return outcome
})
break
@@ -241,35 +286,39 @@ export async function grantWorkspaceAccessDirectly(
}
if (result.outcome === 'unchanged') return result
try {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, input.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
if (result.outcome === 'added') {
try {
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, input.workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId: input.workspaceId,
envKeys: wsEnvKeys,
actingUserId: input.userId,
})
}
} catch (error) {
logger.error('Failed to sync workspace env credentials after direct grant', {
workspaceId: input.workspaceId,
envKeys: wsEnvKeys,
actingUserId: input.userId,
userId: input.userId,
error,
})
}
} catch (error) {
logger.error('Failed to sync workspace env credentials after direct grant', {
workspaceId: input.workspaceId,
userId: input.userId,
error,
})
}
try {
PlatformEvents.workspaceMemberAdded({
workspaceId: input.workspaceId,
addedBy: input.actorId,
addedUserId: input.userId,
role: input.permission,
})
if (result.outcome === 'added') {
PlatformEvents.workspaceMemberAdded({
workspaceId: input.workspaceId,
addedBy: input.actorId,
addedUserId: input.userId,
role: input.permission,
})
}
} catch {
/**
* Telemetry must not fail the grant.
@@ -278,26 +327,30 @@ export async function grantWorkspaceAccessDirectly(
captureServerEvent(
input.actorId,
'workspace_member_added',
result.outcome === 'updated' ? 'workspace_member_role_changed' : 'workspace_member_added',
{
workspace_id: input.workspaceId,
member_role: input.permission,
...(result.outcome === 'updated'
? { new_role: result.permission }
: { member_role: input.permission }),
},
{
groups: { workspace: input.workspaceId },
}
{ groups: { workspace: input.workspaceId } }
)
recordAudit({
const audit = {
workspaceId: input.workspaceId,
actorId: input.actorId,
actorName: input.actorName,
actorEmail: input.actorEmail,
action: AuditAction.MEMBER_ADDED,
actorId: input.auditActor ? input.auditActor.id : input.actorId,
actorName: input.auditActor ? input.auditActor.name : input.actorName,
actorEmail: input.auditActor ? input.auditActor.email : input.actorEmail,
action:
result.outcome === 'updated' ? AuditAction.MEMBER_ROLE_CHANGED : AuditAction.MEMBER_ADDED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: input.workspaceId,
resourceName: normalizedEmail,
description: `Added existing organization member ${normalizedEmail} as ${input.permission}`,
description:
result.outcome === 'updated'
? `Changed ${normalizedEmail} from ${result.previousPermission} to ${result.permission}`
: `Added existing organization member ${normalizedEmail} as ${input.permission}`,
metadata: {
targetEmail: normalizedEmail,
targetRole: input.permission,
@@ -306,24 +359,46 @@ export async function grantWorkspaceAccessDirectly(
addedUserId: input.userId,
},
request: input.request,
})
if (input.notify ?? true) {
try {
await sendWorkspaceAddedEmail({
email: normalizedEmail,
inviterName: input.actorName,
workspaceId: input.workspaceId,
workspaceName: input.workspaceName,
})
} catch (error) {
logger.error('Failed to send workspace added email', {
workspaceId: input.workspaceId,
email: normalizedEmail,
error,
})
}
} as const
if (input.auditOperationId) {
await recordAuditOnce(
`${input.auditOperationId}:workspace-access:${input.workspaceId}:${input.userId}`,
audit
)
} else {
recordAudit(audit)
}
return result
}
const sendDirectGrantEmail: OutboxHandler = async (rawPayload) => {
if (
!isRecordLike(rawPayload) ||
typeof rawPayload.email !== 'string' ||
typeof rawPayload.inviterName !== 'string' ||
typeof rawPayload.workspaceId !== 'string' ||
typeof rawPayload.workspaceName !== 'string'
) {
throw new Error('Invalid workspace-added email payload')
}
const payload: DirectGrantEmailPayload = {
email: rawPayload.email,
inviterName: rawPayload.inviterName,
workspaceId: rawPayload.workspaceId,
workspaceName: rawPayload.workspaceName,
}
const result = await sendWorkspaceAddedEmail({
email: payload.email,
inviterName: payload.inviterName,
workspaceId: payload.workspaceId,
workspaceName: payload.workspaceName,
})
if (!result.success) {
throw new Error(result.error || 'Failed to send workspace-added email')
}
}
export const directGrantOutboxHandlers = {
[DIRECT_GRANT_EMAIL_EVENT_TYPE]: sendDirectGrantEmail,
} as const
+5
View File
@@ -0,0 +1,5 @@
export const INVITATION_EXPIRY_DAYS = 7
export function computeInvitationExpiry(daysFromNow = INVITATION_EXPIRY_DAYS): Date {
return new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000)
}
+5
View File
@@ -0,0 +1,5 @@
/** One invitation authorizes and stamps each workspace, so the fan-out is bounded. */
export const MAX_INVITE_WORKSPACES = 50
/** Email delivery is intentionally sequential so partial failures remain attributable. */
export const MAX_INVITE_EMAILS = 100
+1 -1
View File
@@ -131,7 +131,7 @@ async function resolveInvitationOrganizationId(
return uniqueScopes.length === 1 ? uniqueScopes[0] : input.organizationId
}
async function findPendingOrganizationInvitation(
export async function findPendingOrganizationInvitation(
executor: DbOrTx,
organizationId: string,
email: string
@@ -31,6 +31,7 @@ const {
mockCancelPendingInvitation,
mockRevertPendingInvitationGrants,
mockFindPendingGrantWorkspaceIds,
mockFindPendingOrganizationInvitation,
mockGetInvitePlanCategoryForUser,
mockIsOrganizationOwnerOrAdmin,
mockWorkspaceMemberInvited,
@@ -50,6 +51,7 @@ const {
mockCancelPendingInvitation: vi.fn(),
mockRevertPendingInvitationGrants: vi.fn(),
mockFindPendingGrantWorkspaceIds: vi.fn(),
mockFindPendingOrganizationInvitation: vi.fn(),
mockGetInvitePlanCategoryForUser: vi.fn(),
mockIsOrganizationOwnerOrAdmin: vi.fn(),
mockWorkspaceMemberInvited: vi.fn(),
@@ -84,6 +86,7 @@ vi.mock('@/lib/invitations/send', () => ({
cancelPendingInvitation: mockCancelPendingInvitation,
revertPendingInvitationGrants: mockRevertPendingInvitationGrants,
findPendingGrantWorkspaceIds: mockFindPendingGrantWorkspaceIds,
findPendingOrganizationInvitation: mockFindPendingOrganizationInvitation,
}))
vi.mock('@/lib/posthog/server', () => ({
@@ -192,7 +195,10 @@ describe('createWorkspaceInvitation', () => {
mutationOrganizationId: 'org-1',
})
mockSendInvitationEmail.mockResolvedValue({ success: true })
mockCancelPendingInvitation.mockResolvedValue(true)
mockRevertPendingInvitationGrants.mockResolvedValue(true)
mockFindPendingGrantWorkspaceIds.mockResolvedValue(new Set())
mockFindPendingOrganizationInvitation.mockResolvedValue(null)
mockGetInvitePlanCategoryForUser.mockResolvedValue('free')
})
@@ -260,6 +266,25 @@ describe('createWorkspaceInvitation', () => {
expect(mockSendInvitationEmail).toHaveBeenCalled()
})
it('lets privileged admin callers reject a cross-organization internal invitation', async () => {
queueWhereResponses([[{ id: 'user-3', email: 'ext@example.com' }], []])
mockGetUserOrganization.mockResolvedValueOnce({ organizationId: 'org-2', role: 'member' })
await expect(
createWorkspaceInvitation({
context: makeContext(),
email: 'ext@example.com',
permission: 'read',
membership: 'member',
rejectCrossOrganization: true,
request,
})
).rejects.toMatchObject({ status: 409 })
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
})
it('creates an internal pending invitation when the registered user has no org', async () => {
queueWhereResponses([[{ id: 'user-4', email: 'noorg@example.com' }], []])
mockGetUserOrganization.mockResolvedValueOnce(null)
@@ -520,6 +545,79 @@ describe('createWorkspaceInvitation', () => {
)
})
it('reuses an existing Enterprise seat reservation when extending a pending invitation', async () => {
queueTableRows(userTable, [])
const context = makeContext(['ws-2'])
context.targets[0].invitePolicy.requiresSeat = true
mockCreatePendingInvitation.mockImplementationOnce(
async (input: CreatePendingInvitationInput) => {
await input.validateLockedContext?.({
tx: dbChainMock.db as unknown as DbOrTx,
organizationId: 'org-1',
workspaceIds: ['ws-2'],
})
return {
invitationId: 'inv-existing',
token: 'tok-existing',
expiresAt: new Date(),
created: false,
addedWorkspaceIds: ['ws-2'],
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
mutationUpdatedAt: new Date('2026-07-30T12:00:00.000Z'),
mutationOrganizationId: 'org-1',
}
}
)
mockFindPendingOrganizationInvitation.mockResolvedValueOnce({ id: 'inv-existing' })
await createWorkspaceInvitation({
context,
email: 'new@example.com',
permission: 'write',
request,
})
expect(mockValidateSeatAvailability).not.toHaveBeenCalled()
expect(mockCreatePendingInvitation).toHaveBeenCalled()
})
it('checks a new Enterprise seat reservation under the organization lock', async () => {
queueTableRows(userTable, [])
const context = makeContext(['ws-2'])
context.targets[0].invitePolicy.requiresSeat = true
mockValidateSeatAvailability.mockResolvedValueOnce({
canInvite: false,
reason: 'No available seats.',
})
mockCreatePendingInvitation.mockImplementationOnce(
async (input: CreatePendingInvitationInput) => {
await input.validateLockedContext?.({
tx: dbChainMock.db as unknown as DbOrTx,
organizationId: 'org-1',
workspaceIds: ['ws-2'],
})
throw new Error('unreachable')
}
)
await expect(
createWorkspaceInvitation({
context,
email: 'new@example.com',
permission: 'write',
request,
})
).rejects.toMatchObject({ status: 400 })
expect(mockAcquireOrganizationMutationLock).toHaveBeenCalled()
expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1, {
executor: dbChainMock.db,
})
expect(mockAcquireOrganizationMutationLock.mock.invocationCallOrder[0]).toBeLessThan(
mockValidateSeatAvailability.mock.invocationCallOrder[0]
)
})
it('rejects when every selected workspace is already invited', async () => {
queueWhereResponses([[]])
mockFindPendingGrantWorkspaceIds.mockResolvedValueOnce(new Set(['ws-1', 'ws-2']))
@@ -568,4 +666,19 @@ describe('createWorkspaceInvitation', () => {
expectedOrganizationId: 'org-1',
})
})
it('surfaces a concurrent rollback conflict instead of reporting a clean email failure', async () => {
queueWhereResponses([[]])
mockSendInvitationEmail.mockResolvedValueOnce({ success: false, error: 'smtp down' })
mockCancelPendingInvitation.mockResolvedValueOnce(false)
await expect(
createWorkspaceInvitation({
context: makeContext(),
email: 'new@example.com',
permission: 'write',
request,
})
).rejects.toThrow('invitation changed concurrently')
})
})
+202 -44
View File
@@ -1,7 +1,7 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import { type InvitationMembershipIntent, member, permissions, user } from '@sim/db/schema'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { isOrgAdminRole, permissionSatisfies } from '@sim/platform-authz/workspace'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
@@ -25,6 +25,7 @@ import {
cancelPendingInvitation,
createPendingInvitation,
findPendingGrantWorkspaceIds,
findPendingOrganizationInvitation,
revertPendingInvitationGrants,
sendInvitationEmail,
} from '@/lib/invitations/send'
@@ -64,6 +65,8 @@ export interface WorkspaceInvitationContext {
targets: WorkspaceInvitationTarget[]
/** The organization all targets belong to, or null for a personal workspace. */
organizationId: string | null
/** The platform admin to attribute audit entries to; inviter still authorizes product access. */
auditActor?: { id: string | null; name: string; email: string | null }
}
export interface WorkspaceInvitationResult {
@@ -103,6 +106,81 @@ export class WorkspaceInvitationError extends Error {
}
}
async function ensureExistingMemberOrganizationRole({
context,
organizationId,
memberId,
userId,
currentRole,
requestedRole,
email,
request,
}: {
context: WorkspaceInvitationContext
organizationId: string
memberId: string
userId: string
currentRole: string
requestedRole: 'admin' | 'member'
email: string
request?: NextRequest
}): Promise<{ role: string; updated: boolean }> {
if (requestedRole !== 'admin' || isOrgAdminRole(currentRole)) {
return { role: currentRole, updated: false }
}
const updated = await db.transaction(async (tx) => {
await acquireOrganizationUserMutationLocks(tx, {
userId,
organizationIds: [organizationId],
})
const [actorMembership] = await tx
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, context.inviterId)))
.for('update')
.limit(1)
const [targetMembership] = await tx
.select({ role: member.role })
.from(member)
.where(
and(
eq(member.id, memberId),
eq(member.organizationId, organizationId),
eq(member.userId, userId)
)
)
.for('update')
.limit(1)
if (!actorMembership || !isOrgAdminRole(actorMembership.role) || !targetMembership) {
throw new WorkspaceInvitationError({
message: 'Organization membership changed. Refresh and try again.',
status: 409,
email,
})
}
if (isOrgAdminRole(targetMembership.role)) return false
await tx.update(member).set({ role: 'admin' }).where(eq(member.id, memberId))
return true
})
if (updated) {
recordAudit({
actorId: context.auditActor ? context.auditActor.id : context.inviterId,
actorName: context.auditActor ? context.auditActor.name : context.inviterName,
actorEmail: context.auditActor ? context.auditActor.email : context.inviterEmail,
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
resourceName: email,
description: `Promoted ${email} to organization admin during invitation reconciliation`,
metadata: { targetUserId: userId, memberId, previousRole: currentRole, newRole: 'admin' },
request,
})
}
return { role: 'admin', updated }
}
/**
* Authorizes the inviter on every target workspace and resolves the shared
* organization scope. Mixing scopes is rejected: one invitation carries one
@@ -113,11 +191,13 @@ export async function prepareWorkspaceInvitationContext({
inviterId,
inviterName,
inviterEmail,
auditActor,
}: {
workspaceIds: string[]
inviterId: string
inviterName: string
inviterEmail?: string | null
auditActor?: { id: string | null; name: string; email: string | null }
}): Promise<WorkspaceInvitationContext> {
const uniqueWorkspaceIds = [...new Set(workspaceIds)]
if (uniqueWorkspaceIds.length === 0) {
@@ -167,22 +247,7 @@ export async function prepareWorkspaceInvitationContext({
})
}
return { inviterId, inviterName, inviterEmail, targets, organizationId }
}
/**
* Throws the invite-flow seat error when the organization cannot take one
* more internal member.
*/
async function assertSeatAvailable(organizationId: string, email: string): Promise<void> {
const seatValidation = await validateSeatAvailability(organizationId, 1)
if (!seatValidation.canInvite) {
throw new WorkspaceInvitationError({
message: seatValidation.reason || 'No available seats for this organization.',
status: 400,
email,
})
}
return { inviterId, inviterName, inviterEmail, targets, organizationId, auditActor }
}
/**
@@ -213,6 +278,8 @@ async function validateLockedWorkspaceInvitationContext({
existingUserId,
observedInviteeOrganizationId,
requiresOrganizationAdmin,
requiresSeatReservation,
inviteeEmail,
}: {
tx: DbOrTx
context: WorkspaceInvitationContext
@@ -221,6 +288,8 @@ async function validateLockedWorkspaceInvitationContext({
existingUserId?: string
observedInviteeOrganizationId: string | null
requiresOrganizationAdmin: boolean
requiresSeatReservation: boolean
inviteeEmail: string
}): Promise<void> {
/**
* Sending already holds the invitation/workspace advisory locks. Take the
@@ -305,6 +374,21 @@ async function validateLockedWorkspaceInvitationContext({
}
}
if (
organizationId &&
requiresSeatReservation &&
!(await findPendingOrganizationInvitation(tx, organizationId, inviteeEmail))
) {
const seatValidation = await validateSeatAvailability(organizationId, 1, { executor: tx })
if (!seatValidation.canInvite) {
throw new WorkspaceInvitationError({
message: seatValidation.reason || 'No available seats for this organization.',
status: 400,
email: inviteeEmail,
})
}
}
if (existingUserId) {
const currentInviteeMembership = await getUserOrganization(existingUserId, tx)
if ((currentInviteeMembership?.organizationId ?? null) !== observedInviteeOrganizationId) {
@@ -345,6 +429,10 @@ export async function createWorkspaceInvitation({
email,
permission = 'read',
membership = 'member',
rejectCrossOrganization = false,
existingAccessPolicy = 'preserve',
sourceOperationId,
auditOperationId,
request,
}: {
context: WorkspaceInvitationContext
@@ -357,7 +445,15 @@ export async function createWorkspaceInvitation({
* a different organization Sim accounts belong to at most one.
*/
membership?: InvitationMembership
request: NextRequest
/** Admin flows use this to avoid silently changing an internal invite into external access. */
rejectCrossOrganization?: boolean
/** Provisioning may explicitly ensure requested minimum role/access; ordinary invites preserve it. */
existingAccessPolicy?: 'preserve' | 'ensure-at-least'
/** Correlates durable direct-grant notification delivery with a parent operation. */
sourceOperationId?: string
/** Makes invitation/direct-grant audits idempotent for durable callers. */
auditOperationId?: string
request?: NextRequest
}): Promise<WorkspaceInvitationResult> {
const validPermissions: PermissionType[] = ['admin', 'write', 'read']
if (!validPermissions.includes(permission as PermissionType)) {
@@ -379,11 +475,32 @@ export async function createWorkspaceInvitation({
.then((rows) => rows[0])
const existingMembership = existingUser ? await getUserOrganization(existingUser.id) : null
let existingOrganizationRole = existingMembership?.role
let organizationRoleUpdated = false
if (
existingAccessPolicy === 'ensure-at-least' &&
existingUser &&
organizationId &&
existingMembership?.organizationId === organizationId
) {
const ensuredRole = await ensureExistingMemberOrganizationRole({
context,
organizationId,
memberId: existingMembership.memberId,
userId: existingUser.id,
currentRole: existingMembership.role,
requestedRole: membership === 'admin' ? 'admin' : 'member',
email: normalizedEmail,
request,
})
existingOrganizationRole = ensuredRole.role
organizationRoleUpdated = ensuredRole.updated
}
let pendingTargets = context.targets
if (existingUser) {
const accessibleRows = await db
.select({ workspaceId: permissions.entityId })
.select({ workspaceId: permissions.entityId, permission: permissions.permissionType })
.from(permissions)
.where(
and(
@@ -392,17 +509,41 @@ export async function createWorkspaceInvitation({
inArray(permissions.entityId, allWorkspaceIds)
)
)
const accessibleWorkspaceIds = new Set(accessibleRows.map((row) => row.workspaceId))
const accessibleWorkspaceIds = new Set(
accessibleRows
.filter(
(row) =>
existingAccessPolicy === 'preserve' ||
isOrgAdminRole(existingOrganizationRole) ||
permissionSatisfies(row.permission, invitationPermission)
)
.map((row) => row.workspaceId)
)
/**
* Invites never change an existing member's permission role changes go
* through the members list so workspaces they already hold are dropped
* rather than failing the whole invitation.
* Ordinary invites preserve existing permissions, while trusted durable
* provisioning/Admin operations may explicitly ensure the requested minimum.
* Stronger access is always preserved.
*/
pendingTargets = context.targets.filter(
(target) => !accessibleWorkspaceIds.has(target.workspaceId)
)
if (pendingTargets.length === 0) {
if (
existingAccessPolicy === 'ensure-at-least' &&
organizationId &&
existingMembership?.organizationId === organizationId
) {
return {
id: existingUser.id,
email: normalizedEmail,
workspaceIds: [],
permission: invitationPermission,
membershipIntent: 'internal',
instantAdd: true,
outcome: organizationRoleUpdated ? 'updated' : 'unchanged',
}
}
throw new WorkspaceInvitationError({
message: `${normalizedEmail} already has access to ${
context.targets.length === 1 ? 'this workspace' : 'every selected workspace'
@@ -417,7 +558,7 @@ export async function createWorkspaceInvitation({
* with no invitation or acceptance step.
*/
if (organizationId && existingMembership?.organizationId === organizationId) {
let outcome: DirectGrantOutcome['outcome'] = 'unchanged'
let outcome: DirectGrantOutcome['outcome'] = organizationRoleUpdated ? 'updated' : 'unchanged'
for (const target of pendingTargets) {
let directGrant: DirectGrantOutcome
try {
@@ -431,7 +572,11 @@ export async function createWorkspaceInvitation({
actorId: context.inviterId,
actorName: context.inviterName,
actorEmail: context.inviterEmail,
auditActor: context.auditActor,
request,
existingPermissionPolicy: existingAccessPolicy,
sourceOperationId,
auditOperationId,
})
} catch (error) {
if (error instanceof DirectGrantContextChangedError) {
@@ -445,6 +590,7 @@ export async function createWorkspaceInvitation({
throw error
}
if (directGrant.outcome === 'added') outcome = 'added'
else if (directGrant.outcome === 'updated' && outcome === 'unchanged') outcome = 'updated'
}
return {
@@ -466,6 +612,13 @@ export async function createWorkspaceInvitation({
const forcedExternal = Boolean(
organizationId && existingMembership && existingMembership.organizationId !== organizationId
)
if (forcedExternal && rejectCrossOrganization) {
throw new WorkspaceInvitationError({
message: `${normalizedEmail} already belongs to another organization and cannot be invited as an internal member`,
status: 409,
email: normalizedEmail,
})
}
let membershipIntent: InvitationMembershipIntent = 'internal'
if (forcedExternal) {
@@ -509,18 +662,6 @@ export async function createWorkspaceInvitation({
const role: 'admin' | 'member' =
membershipIntent === 'internal' && membership === 'admin' ? 'admin' : 'member'
/**
* Only internal invitees take a seat, and only Enterprise reserves one at
* invite time (`requiresSeat`) Team seats are provisioned on acceptance.
*/
if (
membershipIntent === 'internal' &&
organizationId &&
context.targets[0].invitePolicy.requiresSeat
) {
await assertSeatAvailable(organizationId, normalizedEmail)
}
/**
* Workspaces already covered by a pending invitation are dropped so the
* remaining ones still go out; re-inviting to only those is the duplicate.
@@ -565,6 +706,9 @@ export async function createWorkspaceInvitation({
existingUserId: existingUser?.id,
observedInviteeOrganizationId: existingMembership?.organizationId ?? null,
requiresOrganizationAdmin: membershipIntent === 'internal' && membership === 'admin',
requiresSeatReservation:
membershipIntent === 'internal' && context.targets[0].invitePolicy.requiresSeat,
inviteeEmail: normalizedEmail,
}),
})
} catch (error) {
@@ -630,19 +774,28 @@ export async function createWorkspaceInvitation({
})
if (!emailResult.success) {
let reverted: boolean
if (invitationRecord.created) {
await cancelPendingInvitation(invitationRecord.invitationId, {
reverted = await cancelPendingInvitation(invitationRecord.invitationId, {
expectedUpdatedAt: invitationRecord.mutationUpdatedAt,
expectedOrganizationId: invitationRecord.mutationOrganizationId,
})
} else {
await revertPendingInvitationGrants({
reverted = await revertPendingInvitationGrants({
invitationId: invitationRecord.invitationId,
workspaceIds: invitationRecord.addedWorkspaceIds,
expectedUpdatedAt: invitationRecord.mutationUpdatedAt,
expectedOrganizationId: invitationRecord.mutationOrganizationId,
})
}
if (!reverted) {
throw new WorkspaceInvitationError({
message:
'The email failed after the invitation changed concurrently. Retry to reconcile and deliver the current invitation.',
status: 409,
email: normalizedEmail,
})
}
throw new WorkspaceInvitationError({
message: emailResult.error || 'Failed to send invitation email',
status: 502,
@@ -651,11 +804,11 @@ export async function createWorkspaceInvitation({
}
for (const target of newTargets) {
recordAudit({
const audit = {
workspaceId: target.workspaceId,
actorId: context.inviterId,
actorName: context.inviterName,
actorEmail: context.inviterEmail,
actorId: context.auditActor ? context.auditActor.id : context.inviterId,
actorName: context.auditActor ? context.auditActor.name : context.inviterName,
actorEmail: context.auditActor ? context.auditActor.email : context.inviterEmail,
action: AuditAction.MEMBER_INVITED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: target.workspaceId,
@@ -670,7 +823,12 @@ export async function createWorkspaceInvitation({
invitationId: invitationRecord.invitationId,
},
request,
})
} as const
if (auditOperationId) {
await recordAuditOnce(`${auditOperationId}:workspace-invitation:${target.workspaceId}`, audit)
} else {
recordAudit(audit)
}
}
return {
+267 -2
View File
@@ -1,6 +1,14 @@
/** @vitest-environment node */
import { organization, workspace } from '@sim/db/schema'
import {
invitation,
invitationWorkspaceGrant,
member,
organization,
outboxEvent,
subscription,
workspace,
} from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { PgDialect } from 'drizzle-orm/pg-core'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -8,6 +16,8 @@ import type { WorkspaceMoveError } from '@/lib/workspaces/admin-move'
import {
buildPendingInvitationMergeScopeCondition,
classifyWorkspaceMoveState,
getWorkspaceMoveOperation,
getWorkspaceMovePreflight,
invitationMigrationOutboxHandlers,
MIGRATED_INVITATION_EMAIL_EVENT_TYPE,
moveWorkspaceToOrganization,
@@ -19,6 +29,7 @@ vi.unmock('drizzle-orm')
const {
recordAudit,
recordAuditOnce,
enqueueOrReschedulePendingOutboxEvent,
invalidateWorkspaceTableLimitsCache,
changeWorkspaceStoragePayerInTx,
@@ -26,8 +37,11 @@ const {
getInvitationById,
isInvitationExpired,
sendInvitationEmail,
countPendingSeatInvitations,
resolveSeatCapacity,
} = vi.hoisted(() => ({
recordAudit: vi.fn(),
recordAuditOnce: vi.fn(),
enqueueOrReschedulePendingOutboxEvent: vi.fn(),
invalidateWorkspaceTableLimitsCache: vi.fn(),
changeWorkspaceStoragePayerInTx: vi.fn(),
@@ -35,18 +49,34 @@ const {
getInvitationById: vi.fn(),
isInvitationExpired: vi.fn(() => false),
sendInvitationEmail: vi.fn(),
countPendingSeatInvitations: vi.fn(() => Promise.resolve(0)),
resolveSeatCapacity: vi.fn(() => Promise.resolve(10)),
}))
vi.mock('@sim/audit', () => ({
AuditAction: { WORKSPACE_UPDATED: 'workspace.updated', INVITATION_UPDATED: 'invitation.updated' },
AuditResourceType: { WORKSPACE: 'workspace' },
recordAudit,
recordAuditOnce,
}))
vi.mock('@/lib/billing/organizations/membership', () => ({
acquireOrganizationMutationLock: vi.fn(),
}))
vi.mock('@/lib/billing/storage/payer-transfer', () => ({ changeWorkspaceStoragePayerInTx }))
vi.mock('@/lib/core/outbox/service', () => ({ enqueueOrReschedulePendingOutboxEvent }))
vi.mock('@/lib/billing/validation/seat-management', () => ({
countPendingSeatInvitations,
planHasFixedSeatCap: vi.fn((plan: string) => plan === 'enterprise'),
resolveSeatCapacity,
}))
vi.mock('@/lib/core/outbox/service', () => ({
addOutboxEventSourceOperationId: vi.fn(),
enqueueOrReschedulePendingOutboxEvent,
outboxEventHasSourceOperationId: vi.fn(() => undefined),
outboxPayloadHasSourceOperationId: vi.fn(
(payload: { sourceOperationId?: string; sourceOperationIds?: string[] }, operationId: string) =>
payload.sourceOperationId === operationId || payload.sourceOperationIds?.includes(operationId)
),
}))
vi.mock('@/lib/invitations/core', () => ({
getInvitationById,
isInvitationExpired,
@@ -170,6 +200,58 @@ describe('classifyWorkspaceMoveState', () => {
})
})
describe('workspace move invitation bounds', () => {
it('blocks a move preflight instead of truncating an oversized pending invitation set', async () => {
queueTableRows(workspace, [personalWorkspace])
queueTableRows(organization, [destination])
queueTableRows(
invitationWorkspaceGrant,
Array.from({ length: 1_001 }, (_, index) => ({
id: `invitation-${index}`,
email: `invitee-${index}@example.com`,
organizationId: null,
membershipIntent: 'internal',
permission: 'read',
}))
)
await expect(getWorkspaceMovePreflight('workspace-1', 'org-1')).rejects.toMatchObject({
code: 'invitation-volume-exceeded',
message: expect.stringContaining('none were migrated'),
})
})
it('blocks a move when bounded invitation rows expand into too many workspace grants', async () => {
queueTableRows(workspace, [personalWorkspace])
queueTableRows(organization, [destination])
queueTableRows(invitationWorkspaceGrant, [
{
id: 'invitation-1',
email: 'one@example.com',
organizationId: null,
membershipIntent: 'internal',
permission: 'read',
},
{
id: 'invitation-2',
email: 'two@example.com',
organizationId: null,
membershipIntent: 'internal',
permission: 'read',
},
])
queueTableRows(invitationWorkspaceGrant, [
{ invitationId: 'invitation-1', value: 5_001 },
{ invitationId: 'invitation-2', value: 5_000 },
])
await expect(getWorkspaceMovePreflight('workspace-1', 'org-1')).rejects.toMatchObject({
code: 'invitation-volume-exceeded',
message: expect.stringContaining('none were migrated'),
})
})
})
describe('pending invitation destination identity', () => {
it('matches by email and organization without splitting internal/external intent', () => {
const dialect = new PgDialect()
@@ -370,6 +452,189 @@ describe('moveWorkspaceToOrganization retries', () => {
expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled()
})
it('repairs the idempotent move audit when a committed move is retried after response loss', async () => {
queueMoveSelects(movedWorkspace)
await moveWorkspaceToOrganization({
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
auditOperationId: 'operation-1',
})
expect(recordAuditOnce).toHaveBeenCalledWith(
`operation-1:workspace-move:${movedWorkspace.id}`,
expect.objectContaining({
action: 'workspace.updated',
metadata: expect.objectContaining({ recoveredAfterResponseLoss: true }),
})
)
expect(recordAudit).not.toHaveBeenCalled()
})
it('persists a standalone operation marker atomically with a new move', async () => {
queueMoveSelects(personalWorkspace)
await moveWorkspaceToOrganization({
workspaceId: personalWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
expectedOwnerId: personalWorkspace.ownerId,
auditOperationId: 'operation-1',
operationCorrelationId: 'operation-1',
durableOperationId: 'operation-1',
})
expect(dbChainMockFns.values.mock.calls.map(([values]) => values)).toContainEqual(
expect.objectContaining({
id: 'operation-1',
eventType: 'admin.workspace-move-operation',
status: 'completed',
payload: {
request: {
workspaceId: personalWorkspace.id,
destinationOrganizationId: destination.id,
expectedOwnerId: personalWorkspace.ownerId,
},
audit: {
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: expect.any(String),
},
},
})
)
})
it('refuses a move that would exceed the locked Enterprise seat capacity', async () => {
queueMoveSelects(personalWorkspace)
queueTableRows(subscription, [
{ id: 'subscription-1', plan: 'enterprise', status: 'active', metadata: { seats: 1 } },
])
queueTableRows(member, [{ value: 1 }])
queueTableRows(invitation, [])
queueTableRows(invitation, [])
queueTableRows(invitationWorkspaceGrant, [
{
id: 'invitation-1',
email: 'new-seat@example.com',
organizationId: null,
membershipIntent: 'internal',
permission: 'read',
},
])
queueTableRows(invitationWorkspaceGrant, [{ invitationId: 'invitation-1', value: 1 }])
resolveSeatCapacity.mockResolvedValueOnce(1)
await expect(
moveWorkspaceToOrganization({
workspaceId: personalWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
})
).rejects.toMatchObject<Partial<WorkspaceMoveError>>({ code: 'seat-capacity-exceeded' })
expect(changeWorkspaceStoragePayerInTx).not.toHaveBeenCalled()
})
it('does not let a new operation ID claim a workspace moved by another operation', async () => {
queueMoveSelects(movedWorkspace)
await expect(
moveWorkspaceToOrganization({
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
expectedOwnerId: movedWorkspace.ownerId,
auditOperationId: 'operation-2',
operationCorrelationId: 'operation-2',
durableOperationId: 'operation-2',
})
).rejects.toMatchObject<Partial<WorkspaceMoveError>>({
code: 'already-organization-workspace',
})
expect(recordAuditOnce).not.toHaveBeenCalled()
})
it('recovers an already-moved workspace only for its exact durable operation', async () => {
queueMoveSelects(movedWorkspace)
queueTableRows(outboxEvent, [
{
eventType: 'admin.workspace-move-operation',
status: 'completed',
payload: {
request: {
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
expectedOwnerId: movedWorkspace.ownerId,
},
audit: {
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
},
},
},
])
await expect(
moveWorkspaceToOrganization({
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
adminEmail: 'admin@sim.ai',
expectedOwnerId: movedWorkspace.ownerId,
auditOperationId: 'operation-1',
operationCorrelationId: 'operation-1',
durableOperationId: 'operation-1',
})
).resolves.toMatchObject({ workspace: { id: movedWorkspace.id } })
})
it('keeps a completed move recoverable after a later workspace-owner change', async () => {
const currentWorkspace = { ...movedWorkspace, ownerId: 'new-owner' }
queueTableRows(outboxEvent, [
{
eventType: 'admin.workspace-move-operation',
status: 'completed',
payload: {
request: {
workspaceId: movedWorkspace.id,
destinationOrganizationId: destination.id,
expectedOwnerId: movedWorkspace.ownerId,
},
audit: {
actor: { id: null, name: 'Admin Panel', email: 'admin@sim.ai' },
previousBillingOwnerId: personalWorkspace.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: '2026-08-20T00:00:00.000Z',
},
},
},
])
queueTableRows(workspace, [currentWorkspace])
queueTableRows(workspace, [currentWorkspace])
queueTableRows(organization, [destination])
await expect(
getWorkspaceMoveOperation(
movedWorkspace.id,
destination.id,
movedWorkspace.ownerId,
'operation-1'
)
).resolves.toMatchObject({ workspace: { id: movedWorkspace.id, ownerId: 'new-owner' } })
expect(recordAuditOnce).toHaveBeenCalledWith(
`operation-1:workspace-move:${movedWorkspace.id}`,
expect.objectContaining({
actorEmail: 'admin@sim.ai',
metadata: expect.objectContaining({ requestOperationId: 'operation-1' }),
})
)
})
it('takes shared advisory locks before the workspace row lock and payer mutation', async () => {
queueMoveSelects(personalWorkspace)
+638 -40
View File
@@ -1,10 +1,11 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { AuditAction, AuditResourceType, recordAudit, recordAuditOnce } from '@sim/audit'
import { db } from '@sim/db'
import {
invitation,
invitationWorkspaceGrant,
member,
organization,
outboxEvent,
permissions,
subscription,
user,
@@ -42,8 +43,11 @@ import {
resolveSeatCapacity,
} from '@/lib/billing/validation/seat-management'
import {
addOutboxEventSourceOperationId,
enqueueOrReschedulePendingOutboxEvent,
type OutboxHandler,
outboxEventHasSourceOperationId,
outboxPayloadHasSourceOperationId,
} from '@/lib/core/outbox/service'
import type { DbOrTx } from '@/lib/db/types'
import { getInvitationById, isInvitationExpired } from '@/lib/invitations/core'
@@ -58,10 +62,16 @@ import {
import { WORKSPACE_MODE } from '@/lib/workspaces/policy'
const logger = createLogger('AdminWorkspaceMove')
// A dashboard member add may move several grants from one invitation in
// consecutive short transactions. Let that split/merge sequence settle before
// the outbox resolves the live invitation and sends its final token.
/**
* A dashboard member add may move several grants from one invitation in
* consecutive short transactions. Let that split/merge sequence settle before
* the outbox resolves the live invitation and sends its final token.
*/
const MIGRATED_INVITATION_EMAIL_SETTLE_MS = 60_000
const MAX_WORKSPACE_MOVE_PENDING_INVITATIONS = 1_000
const MAX_WORKSPACE_MOVE_GRANTS_PER_INVITATION = 1_000
const MAX_WORKSPACE_MOVE_TOTAL_INVITATION_GRANTS = 10_000
const MAX_WORKSPACE_MOVE_RELATED_INVITATIONS = 1_000
export class WorkspaceMoveError extends Error {
constructor(
@@ -71,6 +81,8 @@ export class WorkspaceMoveError extends Error {
| 'organization-not-found'
| 'workspace-owner-changed'
| 'already-organization-workspace'
| 'seat-capacity-exceeded'
| 'invitation-volume-exceeded'
) {
super(message)
this.name = 'WorkspaceMoveError'
@@ -116,6 +128,21 @@ export interface WorkspaceMovePreflight {
warning: string | null
}
export interface WorkspaceMoveOperationView extends WorkspaceMovePreflight {
operationId: string
followUpJobs: {
selected: number
completed: number
pending: number
failedCount: number
failed: Array<{
eventId: string
invitationId: string
error: string | null
}>
}
}
interface InvitationMigrationEvent {
invitationId: string
outcome: 'migrated' | 'split' | 'merged'
@@ -144,11 +171,101 @@ interface MoveTransactionResult {
previousBillingOwnerId: string
destinationOwnerId: string
organizationAssignedAt: Date | null
durableAudit: AdminWorkspaceMoveOperationPayload['audit'] | null
invitationEvents: InvitationMigrationEvent[]
summary: WorkspaceMovePreflight
}
export const MIGRATED_INVITATION_EMAIL_EVENT_TYPE = 'invitation.send-migrated-link'
export const ADMIN_WORKSPACE_MOVE_OPERATION_EVENT_TYPE = 'admin.workspace-move-operation'
interface AdminWorkspaceMoveOperationRequest {
workspaceId: string
destinationOrganizationId: string
expectedOwnerId: string | null
}
interface AdminWorkspaceMoveOperationPayload {
request: AdminWorkspaceMoveOperationRequest
audit: {
actor: { id: string | null; name: string; email: string | null }
previousBillingOwnerId: string
newBillingOwnerId: string
organizationAssignedAt: string
}
}
function parseAdminWorkspaceMoveOperationPayload(
payload: unknown
): AdminWorkspaceMoveOperationPayload | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
const record = payload as Record<string, unknown>
const request = record.request
const audit = record.audit
if (
!request ||
typeof request !== 'object' ||
Array.isArray(request) ||
!audit ||
typeof audit !== 'object' ||
Array.isArray(audit)
) {
return null
}
const requestRecord = request as Record<string, unknown>
const auditRecord = audit as Record<string, unknown>
const actor = auditRecord.actor
if (
typeof requestRecord.workspaceId !== 'string' ||
typeof requestRecord.destinationOrganizationId !== 'string' ||
(requestRecord.expectedOwnerId !== null && typeof requestRecord.expectedOwnerId !== 'string') ||
!actor ||
typeof actor !== 'object' ||
Array.isArray(actor) ||
typeof auditRecord.previousBillingOwnerId !== 'string' ||
typeof auditRecord.newBillingOwnerId !== 'string' ||
typeof auditRecord.organizationAssignedAt !== 'string'
) {
return null
}
const actorRecord = actor as Record<string, unknown>
if (
(actorRecord.id !== null && typeof actorRecord.id !== 'string') ||
typeof actorRecord.name !== 'string' ||
(actorRecord.email !== null && typeof actorRecord.email !== 'string')
) {
return null
}
return {
request: {
workspaceId: requestRecord.workspaceId,
destinationOrganizationId: requestRecord.destinationOrganizationId,
expectedOwnerId: requestRecord.expectedOwnerId,
},
audit: {
actor: {
id: actorRecord.id,
name: actorRecord.name,
email: actorRecord.email,
},
previousBillingOwnerId: auditRecord.previousBillingOwnerId,
newBillingOwnerId: auditRecord.newBillingOwnerId,
organizationAssignedAt: auditRecord.organizationAssignedAt,
},
}
}
function workspaceMoveOperationMatches(
payload: unknown,
params: AdminWorkspaceMoveOperationRequest
): boolean {
const parsed = parseAdminWorkspaceMoveOperationPayload(payload)
return (
parsed?.request.workspaceId === params.workspaceId &&
parsed.request.destinationOrganizationId === params.destinationOrganizationId &&
parsed.request.expectedOwnerId === params.expectedOwnerId
)
}
class InvitationSetChangedError extends Error {
constructor(readonly invitationIds: string[]) {
@@ -165,12 +282,11 @@ function isConcurrentPendingInvitationInsert(error: unknown): boolean {
}
/** Returns movable personal/grandfathered workspaces by case-insensitive name or exact UUID. */
export async function searchWorkspaceMoveCandidates(
search: string,
limit = 20
): Promise<WorkspaceMoveCandidate[]> {
export async function searchWorkspaceMoveCandidates(search: string, limit = 20, offset = 0) {
const query = search.trim()
if (!query) return []
if (!query) {
return { data: [], pagination: { total: 0, limit, offset, hasMore: false } }
}
const rows = await db
.select({
@@ -183,6 +299,7 @@ export async function searchWorkspaceMoveCandidates(
organizationId: workspace.organizationId,
billedAccountUserId: workspace.billedAccountUserId,
archivedAt: workspace.archivedAt,
total: sql<number>`count(*) over()`.mapWith(Number),
})
.from(workspace)
.innerJoin(user, eq(user.id, workspace.ownerId))
@@ -195,8 +312,22 @@ export async function searchWorkspaceMoveCandidates(
)
.orderBy(asc(workspace.name))
.limit(Math.min(Math.max(limit, 1), 50))
.offset(Math.max(offset, 0))
return rows.map(({ archivedAt, ...row }) => ({ ...row, archived: archivedAt !== null }))
const boundedLimit = Math.min(Math.max(limit, 1), 50)
const total = rows[0]?.total ?? 0
return {
data: rows.map(({ archivedAt, total: _total, ...row }) => ({
...row,
archived: archivedAt !== null,
})),
pagination: {
total,
limit: boundedLimit,
offset: Math.max(offset, 0),
hasMore: Math.max(offset, 0) + rows.length < total,
},
}
}
/** Builds the human-reviewable summary shown before a workspace move. */
@@ -275,7 +406,7 @@ export async function getWorkspaceMovePreflight(
})
const warning =
seatCapacity !== null && currentMembers + projectedPendingInternalSeats > seatCapacity
? `${currentMembers} current member${currentMembers === 1 ? '' : 's'} plus ${projectedPendingInternalSeats} pending internal invitation${projectedPendingInternalSeats === 1 ? '' : 's'} would exceed the ${seatCapacity}-seat Enterprise capacity if all are accepted.`
? `This move is blocked: ${currentMembers} current member${currentMembers === 1 ? '' : 's'} plus ${projectedPendingInternalSeats} pending internal invitation reservation${projectedPendingInternalSeats === 1 ? '' : 's'} exceed the ${seatCapacity}-seat Enterprise capacity.`
: null
return {
@@ -302,6 +433,12 @@ export async function moveWorkspaceToOrganization(params: {
workspaceId: string
destinationOrganizationId: string
adminEmail: string
auditActor?: { id: string | null; name: string; email: string | null }
/** Makes the move audit recoverable if the DB commit succeeds before the caller gets a response. */
auditOperationId?: string
operationCorrelationId?: string
/** Persists a standalone Admin operation marker atomically with the move. */
durableOperationId?: string
/** Reject a stale batch selection instead of moving a newly owned workspace. */
expectedOwnerId?: string
}): Promise<WorkspaceMovePreflight> {
@@ -324,6 +461,38 @@ export async function moveWorkspaceToOrganization(params: {
})
await acquireOrganizationMutationLock(tx, params.destinationOrganizationId)
const durableOperationRequest: AdminWorkspaceMoveOperationRequest = {
workspaceId: params.workspaceId,
destinationOrganizationId: params.destinationOrganizationId,
expectedOwnerId: params.expectedOwnerId ?? null,
}
const [existingDurableOperation] = params.durableOperationId
? await tx
.select({
eventType: outboxEvent.eventType,
status: outboxEvent.status,
payload: outboxEvent.payload,
})
.from(outboxEvent)
.where(eq(outboxEvent.id, params.durableOperationId))
.for('update')
.limit(1)
: []
if (
existingDurableOperation &&
(existingDurableOperation.eventType !== ADMIN_WORKSPACE_MOVE_OPERATION_EVENT_TYPE ||
existingDurableOperation.status !== 'completed' ||
!workspaceMoveOperationMatches(
existingDurableOperation.payload,
durableOperationRequest
))
) {
throw new WorkspaceMoveError(
'Workspace move operation ID is already bound to different parameters',
'already-organization-workspace'
)
}
const currentInvitationIds = await findInvitationMigrationLockIds(
params.workspaceId,
params.destinationOrganizationId,
@@ -367,16 +536,65 @@ export async function moveWorkspaceToOrganization(params: {
}
if (moveState === 'already-moved') {
if (params.durableOperationId && !existingDurableOperation) {
throw new WorkspaceMoveError(
'Workspace was already moved outside this confirmed operation',
'already-organization-workspace'
)
}
return {
performedMove: false,
previousBillingOwnerId: workspaceRow.billedAccountUserId,
destinationOwnerId: destination.ownerId,
organizationAssignedAt: null,
durableAudit: existingDurableOperation
? (parseAdminWorkspaceMoveOperationPayload(existingDurableOperation.payload)?.audit ??
null)
: null,
invitationEvents: [],
summary: await getMovedWorkspaceSummary(tx, params.workspaceId, destination),
} satisfies MoveTransactionResult
}
const [enterpriseSubscription] = await tx
.select({
id: subscription.id,
plan: subscription.plan,
status: subscription.status,
metadata: subscription.metadata,
})
.from(subscription)
.where(
and(
eq(subscription.referenceId, params.destinationOrganizationId),
eq(subscription.plan, 'enterprise'),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
if (enterpriseSubscription && hasPaidSubscriptionStatus(enterpriseSubscription.status)) {
const [capacity, memberRows, movedWorkspaceInvitations] = await Promise.all([
resolveSeatCapacity(enterpriseSubscription, tx),
tx
.select({ value: count() })
.from(member)
.where(eq(member.organizationId, params.destinationOrganizationId)),
getPendingInvitationSummaries(params.workspaceId, tx),
])
const projectedPendingSeats = await getProjectedDestinationPendingSeatCount({
destinationOrganizationId: params.destinationOrganizationId,
movedWorkspaceInvitations,
executor: tx,
})
const currentMembers = memberRows[0]?.value ?? 0
if (currentMembers + projectedPendingSeats > capacity) {
throw new WorkspaceMoveError(
`Moving this workspace would require ${currentMembers + projectedPendingSeats} occupied or reserved seats, above the ${capacity}-seat Enterprise capacity`,
'seat-capacity-exceeded'
)
}
}
const now = new Date()
await expireLockedPendingInvitations(tx, candidateInvitationIds, now)
const lockedInvitationIds = await lockCurrentPendingInvitations(tx, params.workspaceId, now)
@@ -387,15 +605,27 @@ export async function moveWorkspaceToOrganization(params: {
now,
})
for (const invitationId of migration.invitationsToEmail) {
await enqueueOrReschedulePendingOutboxEvent(
const invitationEmailEventId = await enqueueOrReschedulePendingOutboxEvent(
tx,
MIGRATED_INVITATION_EMAIL_EVENT_TYPE,
{ invitationId },
{
invitationId,
...(params.operationCorrelationId
? { sourceOperationIds: [params.operationCorrelationId] }
: {}),
},
{
availableAt: new Date(now.getTime() + MIGRATED_INVITATION_EMAIL_SETTLE_MS),
coalesceOn: { payloadKey: 'invitationId', payloadValue: invitationId },
}
)
if (params.operationCorrelationId) {
await addOutboxEventSourceOperationId(
tx,
invitationEmailEventId,
params.operationCorrelationId
)
}
}
await changeWorkspaceStoragePayerInTx(tx, {
@@ -417,6 +647,29 @@ export async function moveWorkspaceToOrganization(params: {
})
.where(eq(workspace.id, params.workspaceId))
const durableAudit: AdminWorkspaceMoveOperationPayload['audit'] | null =
params.durableOperationId
? {
actor: params.auditActor ?? {
id: null,
name: 'Admin Panel',
email: params.adminEmail,
},
previousBillingOwnerId: workspaceRow.billedAccountUserId,
newBillingOwnerId: destination.ownerId,
organizationAssignedAt: now.toISOString(),
}
: null
if (params.durableOperationId && durableAudit) {
await tx.insert(outboxEvent).values({
id: params.durableOperationId,
eventType: ADMIN_WORKSPACE_MOVE_OPERATION_EVENT_TYPE,
payload: { request: durableOperationRequest, audit: durableAudit },
status: 'completed',
processedAt: now,
})
}
await tx
.insert(permissions)
.values({
@@ -438,6 +691,7 @@ export async function moveWorkspaceToOrganization(params: {
previousBillingOwnerId: workspaceRow.billedAccountUserId,
destinationOwnerId: destination.ownerId,
organizationAssignedAt: now,
durableAudit,
invitationEvents: migration.invitationEvents,
summary: await getMovedWorkspaceSummary(tx, params.workspaceId, destination),
} satisfies MoveTransactionResult
@@ -464,6 +718,22 @@ export async function moveWorkspaceToOrganization(params: {
}
if (!result.performedMove) {
if (params.auditOperationId && result.durableAudit) {
await recordDurableWorkspaceMoveAudit(
params.auditOperationId,
params.workspaceId,
params.destinationOrganizationId,
result.durableAudit
)
} else if (params.auditOperationId) {
await recordWorkspaceMoveAudit({
params,
previousBillingOwnerId: null,
newBillingOwnerId: result.destinationOwnerId,
organizationAssignedAt: null,
recovered: true,
})
}
logger.info('Workspace was already in destination organization', {
workspaceId: params.workspaceId,
destinationOrganizationId: params.destinationOrganizationId,
@@ -473,29 +743,29 @@ export async function moveWorkspaceToOrganization(params: {
invalidateWorkspaceTableLimitsCache(params.workspaceId)
recordAudit({
workspaceId: params.workspaceId,
actorId: null,
actorName: 'Admin Panel',
actorEmail: params.adminEmail,
action: AuditAction.WORKSPACE_UPDATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: params.workspaceId,
description: 'Moved workspace into an organization',
metadata: {
destinationOrganizationId: params.destinationOrganizationId,
if (params.auditOperationId && result.durableAudit) {
await recordDurableWorkspaceMoveAudit(
params.auditOperationId,
params.workspaceId,
params.destinationOrganizationId,
result.durableAudit
)
} else {
await recordWorkspaceMoveAudit({
params,
previousBillingOwnerId: result.previousBillingOwnerId,
newBillingOwnerId: result.destinationOwnerId,
organizationAssignedAt: result.organizationAssignedAt?.toISOString(),
},
})
organizationAssignedAt: result.organizationAssignedAt,
recovered: false,
})
}
for (const event of result.invitationEvents) {
recordAudit({
workspaceId: params.workspaceId,
actorId: null,
actorName: 'Admin Panel',
actorEmail: params.adminEmail,
actorId: params.auditActor ? params.auditActor.id : null,
actorName: params.auditActor?.name ?? 'Admin Panel',
actorEmail: params.auditActor?.email ?? params.adminEmail,
action: AuditAction.INVITATION_UPDATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: event.invitationId,
@@ -517,6 +787,277 @@ export async function moveWorkspaceToOrganization(params: {
return result.summary
}
async function recordWorkspaceMoveAudit({
params,
previousBillingOwnerId,
newBillingOwnerId,
organizationAssignedAt,
recovered,
}: {
params: {
workspaceId: string
destinationOrganizationId: string
adminEmail: string
auditActor?: { id: string | null; name: string; email: string | null }
auditOperationId?: string
}
previousBillingOwnerId: string | null
newBillingOwnerId: string
organizationAssignedAt: Date | null
recovered: boolean
}): Promise<void> {
const audit = {
workspaceId: params.workspaceId,
actorId: params.auditActor ? params.auditActor.id : null,
actorName: params.auditActor?.name ?? 'Admin Panel',
actorEmail: params.auditActor?.email ?? params.adminEmail,
action: AuditAction.WORKSPACE_UPDATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: params.workspaceId,
description: 'Moved workspace into an organization',
metadata: {
destinationOrganizationId: params.destinationOrganizationId,
previousBillingOwnerId,
newBillingOwnerId,
organizationAssignedAt: organizationAssignedAt?.toISOString() ?? null,
recoveredAfterResponseLoss: recovered,
},
} as const
if (params.auditOperationId) {
await recordAuditOnce(`${params.auditOperationId}:workspace-move:${params.workspaceId}`, audit)
} else {
recordAudit(audit)
}
}
async function recordDurableWorkspaceMoveAudit(
operationId: string,
workspaceId: string,
destinationOrganizationId: string,
audit: AdminWorkspaceMoveOperationPayload['audit']
): Promise<void> {
await recordAuditOnce(`${operationId}:workspace-move:${workspaceId}`, {
workspaceId,
actorId: audit.actor.id,
actorName: audit.actor.name,
actorEmail: audit.actor.email,
action: AuditAction.WORKSPACE_UPDATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: 'Moved workspace into an organization',
metadata: {
destinationOrganizationId,
previousBillingOwnerId: audit.previousBillingOwnerId,
newBillingOwnerId: audit.newBillingOwnerId,
organizationAssignedAt: audit.organizationAssignedAt,
requestOperationId: operationId,
},
})
}
async function getWorkspaceMoveFollowUpJobs(
operationId: string,
executor: DbOrTx = db
): Promise<WorkspaceMoveOperationView['followUpJobs']> {
const [progress] = await executor
.select({
selected: count(),
completed: sql<number>`count(*) filter (where ${outboxEvent.status} = 'completed')`.mapWith(
Number
),
failed: sql<number>`count(*) filter (where ${outboxEvent.status} = 'dead_letter')`.mapWith(
Number
),
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, MIGRATED_INVITATION_EMAIL_EVENT_TYPE),
outboxEventHasSourceOperationId(operationId)
)
)
const selected = progress?.selected ?? 0
const completed = progress?.completed ?? 0
const failedCount = progress?.failed ?? 0
const failedRows =
failedCount > 0
? await executor
.select({
eventId: outboxEvent.id,
invitationId: sql<string | null>`${outboxEvent.payload} ->> 'invitationId'`,
error: outboxEvent.lastError,
})
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, MIGRATED_INVITATION_EMAIL_EVENT_TYPE),
eq(outboxEvent.status, 'dead_letter'),
outboxEventHasSourceOperationId(operationId)
)
)
.orderBy(outboxEvent.createdAt, outboxEvent.id)
.limit(100)
: []
return {
selected,
completed,
pending: Math.max(0, selected - completed - failedCount),
failedCount,
failed: failedRows.flatMap((row) =>
row.invitationId
? [
{
eventId: row.eventId,
invitationId: row.invitationId,
error: row.error,
},
]
: []
),
}
}
export async function toWorkspaceMoveOperationView(
summary: WorkspaceMovePreflight,
operationId: string
): Promise<WorkspaceMoveOperationView> {
return {
...summary,
operationId,
followUpJobs: await getWorkspaceMoveFollowUpJobs(operationId),
}
}
export async function getWorkspaceMoveOperation(
workspaceId: string,
destinationOrganizationId: string,
expectedOwnerId: string | undefined,
operationId: string
): Promise<WorkspaceMoveOperationView> {
const [operation] = await db
.select({
eventType: outboxEvent.eventType,
status: outboxEvent.status,
payload: outboxEvent.payload,
})
.from(outboxEvent)
.where(eq(outboxEvent.id, operationId))
.limit(1)
const operationPayload = parseAdminWorkspaceMoveOperationPayload(operation?.payload)
if (
operation?.eventType !== ADMIN_WORKSPACE_MOVE_OPERATION_EVENT_TYPE ||
operation.status !== 'completed' ||
!operationPayload ||
!workspaceMoveOperationMatches(operationPayload, {
workspaceId,
destinationOrganizationId,
expectedOwnerId: expectedOwnerId ?? null,
})
) {
throw new WorkspaceMoveError(
'Workspace move has not been applied with these confirmed parameters',
'workspace-owner-changed'
)
}
const [workspaceRow] = await searchWorkspaceById(workspaceId)
if (!workspaceRow) throw new WorkspaceMoveError('Workspace not found', 'workspace-not-found')
if (
workspaceRow.organizationId !== destinationOrganizationId ||
workspaceRow.workspaceMode !== WORKSPACE_MODE.ORGANIZATION
) {
throw new WorkspaceMoveError(
'Workspace move has not been applied with these confirmed parameters',
'workspace-owner-changed'
)
}
const destination = await getDestinationOrganization(destinationOrganizationId)
if (!destination) {
throw new WorkspaceMoveError('Destination organization not found', 'organization-not-found')
}
await recordDurableWorkspaceMoveAudit(
operationId,
workspaceId,
destinationOrganizationId,
operationPayload.audit
)
return toWorkspaceMoveOperationView(
await getMovedWorkspaceSummary(db, workspaceId, destination),
operationId
)
}
export async function retryWorkspaceMoveFollowUpJob(params: {
workspaceId: string
destinationOrganizationId: string
expectedOwnerId?: string
operationId: string
jobEventId: string
actor: { id: string | null; name: string; email: string | null }
}): Promise<WorkspaceMoveOperationView> {
await getWorkspaceMoveOperation(
params.workspaceId,
params.destinationOrganizationId,
params.expectedOwnerId,
params.operationId
)
const retried = await db.transaction(async (tx) => {
await acquireOrganizationMutationLock(tx, params.destinationOrganizationId)
const [job] = await tx
.select({
status: outboxEvent.status,
eventType: outboxEvent.eventType,
payload: outboxEvent.payload,
})
.from(outboxEvent)
.where(eq(outboxEvent.id, params.jobEventId))
.for('update')
.limit(1)
if (
!job ||
job.eventType !== MIGRATED_INVITATION_EMAIL_EVENT_TYPE ||
!outboxPayloadHasSourceOperationId(job.payload, params.operationId)
) {
throw new Error('Workspace-move follow-up job not found')
}
if (job.status !== 'dead_letter') return false
await tx
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(eq(outboxEvent.id, params.jobEventId))
return true
})
if (retried) {
await recordAuditOnce(`${params.operationId}:follow-up-retry:${params.jobEventId}`, {
actorId: params.actor.id,
actorName: params.actor.name,
actorEmail: params.actor.email,
action: AuditAction.INVITATION_UPDATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: params.workspaceId,
workspaceId: params.workspaceId,
description: 'Admin retried a migrated invitation email after a workspace move',
metadata: {
destinationOrganizationId: params.destinationOrganizationId,
operationId: params.operationId,
jobEventId: params.jobEventId,
},
})
}
return getWorkspaceMoveOperation(
params.workspaceId,
params.destinationOrganizationId,
params.expectedOwnerId,
params.operationId
)
}
async function searchWorkspaceById(workspaceId: string): Promise<WorkspaceMoveCandidate[]> {
const rows = await db
.select({
@@ -611,6 +1152,14 @@ async function getPendingInvitationSummaries(workspaceId: string, executor: DbOr
gt(invitation.expiresAt, new Date())
)
)
.limit(MAX_WORKSPACE_MOVE_PENDING_INVITATIONS + 1)
if (rows.length > MAX_WORKSPACE_MOVE_PENDING_INVITATIONS) {
throw new WorkspaceMoveError(
`This workspace has more than ${MAX_WORKSPACE_MOVE_PENDING_INVITATIONS.toLocaleString()} pending invitations. Resolve or cancel older invitations before moving it; none were migrated.`,
'invitation-volume-exceeded'
)
}
if (rows.length === 0) return []
const counts = await executor
@@ -623,6 +1172,13 @@ async function getPendingInvitationSummaries(workspaceId: string, executor: DbOr
)
)
.groupBy(invitationWorkspaceGrant.invitationId)
const totalGrantCount = counts.reduce((total, row) => total + row.value, 0)
if (totalGrantCount > MAX_WORKSPACE_MOVE_TOTAL_INVITATION_GRANTS) {
throw new WorkspaceMoveError(
`The pending invitations on this workspace cover more than ${MAX_WORKSPACE_MOVE_TOTAL_INVITATION_GRANTS.toLocaleString()} workspace grants. Resolve or cancel older invitations before moving it; none were migrated.`,
'invitation-volume-exceeded'
)
}
const countById = new Map(counts.map((row) => [row.invitationId, row.value]))
return rows.map((row) => ({
@@ -676,9 +1232,12 @@ export function projectDestinationPendingSeatCount(params: {
async function getProjectedDestinationPendingSeatCount(params: {
destinationOrganizationId: string
movedWorkspaceInvitations: PendingWorkspaceInvitationSummary[]
executor?: DbOrTx
}): Promise<number> {
const executor = params.executor ?? db
const currentDestinationPendingSeats = await countPendingSeatInvitations(
params.destinationOrganizationId
params.destinationOrganizationId,
executor
)
const incomingInternalEmails = [
...new Set(
@@ -694,7 +1253,7 @@ async function getProjectedDestinationPendingSeatCount(params: {
if (incomingInternalEmails.length === 0) return currentDestinationPendingSeats
const [existingDestinationRows, existingMembers] = await Promise.all([
db
executor
.select({ email: invitation.email })
.from(invitation)
.where(
@@ -710,7 +1269,7 @@ async function getProjectedDestinationPendingSeatCount(params: {
)
)
),
db
executor
.select({ email: user.email })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
@@ -733,10 +1292,10 @@ async function getProjectedDestinationPendingSeatCount(params: {
}
/**
* Lock the source invitations plus every pending invitation for the same
* invitees. Those rows are potential split/merge targets and acceptance locks
* the same invitation IDs, so a destination invite cannot be accepted while a
* move is appending a grant to it.
* Lock the source invitations plus pending organization invitations for the
* same invitees. A legacy source can retain grants in several organization
* scopes, and redistribution may merge into any of them. Acceptance locks the
* same invitation IDs, so all possible merge targets must be fenced.
*/
async function findInvitationMigrationLockIds(
workspaceId: string,
@@ -755,6 +1314,13 @@ async function findInvitationMigrationLockIds(
eq(invitationWorkspaceGrant.workspaceId, workspaceId)
)
)
.limit(MAX_WORKSPACE_MOVE_PENDING_INVITATIONS + 1)
if (sourceRows.length > MAX_WORKSPACE_MOVE_PENDING_INVITATIONS) {
throw new WorkspaceMoveError(
`This workspace has more than ${MAX_WORKSPACE_MOVE_PENDING_INVITATIONS.toLocaleString()} pending invitations. Resolve or cancel older invitations before moving it; none were migrated.`,
'invitation-volume-exceeded'
)
}
if (sourceRows.length === 0) return []
const emails = [...new Set(sourceRows.map((row) => normalizeEmail(row.email)))]
@@ -764,12 +1330,18 @@ async function findInvitationMigrationLockIds(
.where(
and(
eq(invitation.status, 'pending'),
gt(invitation.expiresAt, now),
or(...emails.map((email) => sql`lower(${invitation.email}) = ${email}`)),
// Null-org invitations never coalesce, so unrelated personal invites
// for the same email are not mutation targets and need no lock.
isNotNull(invitation.organizationId)
)
)
.limit(MAX_WORKSPACE_MOVE_RELATED_INVITATIONS + 1)
if (relatedRows.length > MAX_WORKSPACE_MOVE_RELATED_INVITATIONS) {
throw new WorkspaceMoveError(
`The pending invitations on this workspace have more than ${MAX_WORKSPACE_MOVE_RELATED_INVITATIONS.toLocaleString()} related organization invitations. Resolve or cancel older invitations before moving it; none were migrated.`,
'invitation-volume-exceeded'
)
}
return [
...new Set([...sourceRows.map((row) => row.id), ...relatedRows.map((row) => row.id)]),
].sort()
@@ -811,6 +1383,13 @@ async function lockCurrentPendingInvitations(
)
.orderBy(invitation.id)
.for('update')
.limit(MAX_WORKSPACE_MOVE_PENDING_INVITATIONS + 1)
if (rows.length > MAX_WORKSPACE_MOVE_PENDING_INVITATIONS) {
throw new WorkspaceMoveError(
`This workspace has more than ${MAX_WORKSPACE_MOVE_PENDING_INVITATIONS.toLocaleString()} pending invitations. Resolve or cancel older invitations before moving it; none were migrated.`,
'invitation-volume-exceeded'
)
}
return [...new Set(rows.map((row) => row.id))]
}
@@ -825,6 +1404,7 @@ async function migratePendingInvitations(
): Promise<{ invitationEvents: InvitationMigrationEvent[]; invitationsToEmail: string[] }> {
const invitationEvents: InvitationMigrationEvent[] = []
const invitationsToEmail = new Set<string>()
let loadedGrantCount = 0
for (const invitationId of params.invitationIds) {
const [source] = await tx
@@ -851,6 +1431,20 @@ async function migratePendingInvitations(
.innerJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId))
.where(eq(invitationWorkspaceGrant.invitationId, source.id))
.orderBy(invitationWorkspaceGrant.workspaceId)
.limit(MAX_WORKSPACE_MOVE_GRANTS_PER_INVITATION + 1)
if (grants.length > MAX_WORKSPACE_MOVE_GRANTS_PER_INVITATION) {
throw new WorkspaceMoveError(
`Pending invitation ${source.id} covers more than ${MAX_WORKSPACE_MOVE_GRANTS_PER_INVITATION.toLocaleString()} workspaces. Resolve or cancel it before moving this workspace; none were migrated.`,
'invitation-volume-exceeded'
)
}
loadedGrantCount += grants.length
if (loadedGrantCount > MAX_WORKSPACE_MOVE_TOTAL_INVITATION_GRANTS) {
throw new WorkspaceMoveError(
`The pending invitations on this workspace cover more than ${MAX_WORKSPACE_MOVE_TOTAL_INVITATION_GRANTS.toLocaleString()} workspace grants. Resolve or cancel older invitations before moving it; none were migrated.`,
'invitation-volume-exceeded'
)
}
const existingDestination = await findPendingInvitationForScope(tx, {
email: source.email,
@@ -1093,7 +1687,11 @@ async function mergeGrant(
})
}
const sendMigratedInvitationLink: OutboxHandler<{ invitationId: string }> = async (payload) => {
const sendMigratedInvitationLink: OutboxHandler<{
invitationId: string
sourceOperationId?: string
sourceOperationIds?: string[]
}> = async (payload) => {
const migrated = await getInvitationById(payload.invitationId)
if (!migrated || migrated.status !== 'pending' || isInvitationExpired(migrated)) return
const result = await sendInvitationEmail({
+209
View File
@@ -0,0 +1,209 @@
import { db } from '@sim/db'
import { permissions, type WorkspaceMode, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { PlatformEvents } from '@/lib/core/telemetry'
import type { DbOrTx } from '@/lib/db/types'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
import {
getWorkspaceInvitePolicy,
lockWorkspaceCreationContext,
resolveInviteFlags,
WORKSPACE_MODE,
} from '@/lib/workspaces/policy'
const logger = createLogger('WorkspaceCreate')
export interface CreateWorkspaceParams {
userId: string
/** Membership observed by the creation-policy read. */
observedOrganizationId: string | null
name: string
skipDefaultWorkflow?: boolean
explicitColor?: string
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
}
export interface CreatedWorkspace {
id: string
name: string
color: string
ownerId: string
organizationId: string | null
workspaceMode: WorkspaceMode
billedAccountUserId: string
allowPersonalApiKeys: boolean
createdAt: Date
updatedAt: Date
}
/** Emits the canonical best-effort workspace-created platform event after commit. */
export function emitWorkspaceCreatedPlatformEvent(params: {
workspaceId: string
userId: string
name: string
}): void {
try {
PlatformEvents.workspaceCreated(params)
} catch {}
}
/**
* Canonical transaction-enlisted workspace creation primitive.
*
* The caller supplies the creation-policy snapshot. This function revalidates
* that snapshot under the shared organization/user locks before inserting the
* workspace, owner permission, and optional starter workflow atomically.
*/
export async function createWorkspaceInTransaction(
tx: DbOrTx,
{
userId,
observedOrganizationId,
name,
skipDefaultWorkflow = false,
explicitColor,
organizationId,
workspaceMode,
billedAccountUserId,
}: CreateWorkspaceParams
): Promise<CreatedWorkspace> {
const workspaceId = generateId()
const workflowId = generateId()
const now = new Date()
const color = explicitColor || getRandomWorkspaceColor()
const lockedCreationContext = await lockWorkspaceCreationContext(tx, {
userId,
organizationId,
observedOrganizationId,
})
const committedBilledAccountUserId =
workspaceMode === WORKSPACE_MODE.ORGANIZATION
? lockedCreationContext.billedAccountUserId
: billedAccountUserId
await tx.insert(workspace).values({
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId: committedBilledAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
})
const permissionRows = [
{
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
},
]
if (workspaceMode === WORKSPACE_MODE.ORGANIZATION && committedBilledAccountUserId !== userId) {
permissionRows.push({
id: generateId(),
entityType: 'workspace' as const,
entityId: workspaceId,
userId: committedBilledAccountUserId,
permissionType: 'admin' as const,
createdAt: now,
updatedAt: now,
})
}
await tx.insert(permissions).values(permissionRows)
if (!skipDefaultWorkflow) {
await tx.insert(workflow).values({
id: workflowId,
userId,
workspaceId,
folderId: null,
name: 'default-agent',
description: 'Your first workflow - start building here!',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const { workflowState } = buildDefaultWorkflowArtifacts()
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
}
return {
id: workspaceId,
name,
color,
ownerId: userId,
organizationId,
workspaceMode,
billedAccountUserId: committedBilledAccountUserId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
}
}
/** Creates a workspace through the canonical lock-and-insert transaction. */
export async function createWorkspace(params: CreateWorkspaceParams) {
let created: CreatedWorkspace
try {
created = await db.transaction((tx) => createWorkspaceInTransaction(tx, params))
} catch (error) {
logger.error('Failed to create workspace', { userId: params.userId, error })
throw error
}
logger.info(
params.skipDefaultWorkflow
? `Created ${params.workspaceMode} workspace ${created.id} for user ${params.userId}`
: `Created ${params.workspaceMode} workspace ${created.id} with initial workflow for user ${params.userId}`
)
emitWorkspaceCreatedPlatformEvent({
workspaceId: created.id,
userId: params.userId,
name: params.name,
})
const invitePolicy = await getWorkspaceInvitePolicy({
organizationId: created.organizationId,
workspaceMode: created.workspaceMode,
billedAccountUserId: created.billedAccountUserId,
ownerId: created.ownerId,
})
return {
...created,
role: 'owner' as const,
permissions: 'admin' as const,
...resolveInviteFlags(invitePolicy, created.billedAccountUserId === created.ownerId),
}
}
/** The same default personal workspace a first visit would create. */
export async function createDefaultPersonalWorkspaceInTransaction(
tx: DbOrTx,
params: { userId: string; userName: string | null | undefined }
): Promise<CreatedWorkspace> {
const firstName = params.userName?.split(' ')[0] || null
return createWorkspaceInTransaction(tx, {
userId: params.userId,
observedOrganizationId: null,
name: firstName ? `${firstName}'s Workspace` : 'My Workspace',
organizationId: null,
workspaceMode: WORKSPACE_MODE.PERSONAL,
billedAccountUserId: params.userId,
})
}
+2 -1
View File
@@ -1,4 +1,5 @@
export { recordAudit, recordAuditBatch } from './log'
export type { AuditLogParams } from './log'
export { recordAudit, recordAuditBatch, recordAuditOnce } from './log'
export type { AuditActionType, AuditResourceTypeValue } from './types'
export { AuditAction, AuditResourceType } from './types'
export { auditUpdatedFields } from './updated-fields'
+27 -1
View File
@@ -37,7 +37,13 @@ vi.mock('@sim/utils/id', () => ({
}))
import { sleep } from '@sim/utils/helpers'
import { AuditAction, AuditResourceType, recordAudit, recordAuditBatch } from './index'
import {
AuditAction,
AuditResourceType,
recordAudit,
recordAuditBatch,
recordAuditOnce,
} from './index'
const flush = () => sleep(10)
@@ -114,6 +120,26 @@ describe('recordAudit', () => {
)
})
it('awaits an idempotent audit insert under the caller-owned ID', async () => {
await recordAuditOnce('admin-refund:operation-1', {
actorId: 'user-1',
actorName: 'Test User',
actorEmail: 'test@example.com',
action: AuditAction.SUBSCRIPTION_REFUNDED,
resourceType: AuditResourceType.SUBSCRIPTION,
resourceId: 'subscription-1',
})
expect(dbChainMockFns.values).toHaveBeenCalledWith(
expect.objectContaining({
id: 'admin-refund:operation-1',
action: 'subscription.refunded',
resourceId: 'subscription-1',
})
)
expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalledWith({ target: 'id' })
})
it('includes optional denormalized fields when provided', async () => {
recordAudit({
workspaceId: 'ws-1',
+32 -4
View File
@@ -6,7 +6,7 @@ import type { AuditActionType, AuditResourceTypeValue } from './types'
const logger = createLogger('AuditLog')
interface AuditLogParams {
export interface AuditLogParams {
workspaceId?: string | null
/**
* The acting user's id (FK to `user.id`). Pass `null` for genuinely
@@ -75,10 +75,11 @@ export function recordAuditBatch(entries: AuditLogParams[]): void {
*/
function buildAuditRow(
params: AuditLogParams,
actor: { actorId: string | null; actorName?: string | null; actorEmail?: string | null }
actor: { actorId: string | null; actorName?: string | null; actorEmail?: string | null },
id = generateShortId()
) {
return {
id: generateShortId(),
id,
workspaceId: params.workspaceId || null,
actorId: actor.actorId,
action: params.action,
@@ -94,6 +95,24 @@ function buildAuditRow(
}
}
/**
* Persists one audit row under a caller-owned idempotency key.
*
* External operations use this after recovering a provider result from an
* ambiguous response loss. Awaiting the insert closes the crash gap before the
* operation is reported as recovered, while the primary-key conflict turns a
* retry into an authoritative no-op instead of a duplicate audit row.
*/
export async function recordAuditOnce(id: string, params: AuditLogParams): Promise<void> {
if (!id.trim()) throw new Error('Idempotent audit ID must not be empty')
const actor = await resolveAuditActor(params)
await db
.insert(auditLog)
.values(buildAuditRow(params, actor, id))
.onConflictDoNothing({ target: auditLog.id })
}
async function insertAuditLogBatch(entries: AuditLogParams[]): Promise<void> {
if (entries.length === 0) return
@@ -111,6 +130,15 @@ async function insertAuditLogBatch(entries: AuditLogParams[]): Promise<void> {
}
async function insertAuditLog(params: AuditLogParams): Promise<void> {
const actor = await resolveAuditActor(params)
await db.insert(auditLog).values(buildAuditRow(params, actor))
}
async function resolveAuditActor(params: AuditLogParams): Promise<{
actorId: string | null
actorName?: string | null
actorEmail?: string | null
}> {
let { actorName, actorEmail } = params
/**
@@ -144,5 +172,5 @@ async function insertAuditLog(params: AuditLogParams): Promise<void> {
}
}
await db.insert(auditLog).values(buildAuditRow(params, { actorId, actorName, actorEmail }))
return { actorId, actorName, actorEmail }
}
+1
View File
@@ -52,6 +52,7 @@ export const AuditAction = {
// Subscriptions
SUBSCRIPTION_CREATED: 'subscription.created',
SUBSCRIPTION_CANCELLED: 'subscription.cancelled',
SUBSCRIPTION_REFUNDED: 'subscription.refunded',
SUBSCRIPTION_TRANSFERRED: 'subscription.transferred',
ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned',
+1
View File
@@ -183,6 +183,7 @@ export const auditMock = {
CHARGE_DISPUTE_CLOSED: 'charge.dispute_closed',
SUBSCRIPTION_CREATED: 'subscription.created',
SUBSCRIPTION_CANCELLED: 'subscription.cancelled',
SUBSCRIPTION_REFUNDED: 'subscription.refunded',
SUBSCRIPTION_TRANSFERRED: 'subscription.transferred',
ENTERPRISE_SUBSCRIPTION_PROVISIONED: 'subscription.enterprise_provisioned',
CREDENTIAL_ACCESSED: 'credential.accessed',
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 1141,
zodRoutes: 1141,
totalRoutes: 1160,
zodRoutes: 1160,
nonZodRoutes: 0,
} as const