mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
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:
committed by
GitHub
parent
dbbe99e473
commit
ab66ce9149
@@ -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'))
|
||||
}
|
||||
})
|
||||
)
|
||||
+50
@@ -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')
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
+50
@@ -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'))
|
||||
}
|
||||
})
|
||||
)
|
||||
+40
@@ -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'))
|
||||
}
|
||||
})
|
||||
)
|
||||
+35
@@ -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'))
|
||||
}
|
||||
})
|
||||
)
|
||||
+44
@@ -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')
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
+27
@@ -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'))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
+43
@@ -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')
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
+39
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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,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,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user