d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1615 lines
47 KiB
TypeScript
1615 lines
47 KiB
TypeScript
/**
|
|
* Organization Membership Management
|
|
*
|
|
* Shared helpers for adding and removing users from organizations.
|
|
* Used by both regular routes and admin routes to ensure consistent business logic.
|
|
*/
|
|
|
|
import { db } from '@sim/db'
|
|
import {
|
|
invitation,
|
|
member,
|
|
organization,
|
|
permissionGroupMember,
|
|
permissions,
|
|
subscription as subscriptionTable,
|
|
user,
|
|
userStats,
|
|
workspace,
|
|
} from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
|
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
|
import { isPaid, sqlIsPro } from '@/lib/billing/plan-helpers'
|
|
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
|
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
|
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
|
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
|
|
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
|
|
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
|
|
import type { DbOrTx } from '@/lib/db/types'
|
|
import {
|
|
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
|
WorkspaceBillingAccountRemovalError,
|
|
} from '@/lib/workspaces/utils'
|
|
|
|
export { WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR } from '@/lib/workspaces/utils'
|
|
|
|
const logger = createLogger('OrganizationMembership')
|
|
|
|
const ORG_MEMBERSHIP_LOCK_TIMEOUT_MS = 5_000
|
|
|
|
/**
|
|
* Serialize concurrent membership changes for a `(user, org)` pair via a
|
|
* transaction-scoped Postgres advisory lock. Callers acquire it at the top of
|
|
* the transaction that both decides and mutates membership — removal does
|
|
* check-then-delete; acceptance re-checks the member then grants — so an invite
|
|
* acceptance can't interleave with a removal and leave the user with workspace
|
|
* access but no org membership (or vice versa).
|
|
*
|
|
* `pg_advisory_xact_lock` auto-releases at transaction end, so there's no
|
|
* session lock to leak onto a pooled connection, and the `lock_timeout` bounds
|
|
* the wait (it raises SQLSTATE 55P03 instead of hanging) if a holder is stuck.
|
|
*/
|
|
export async function acquireOrgMembershipLock(
|
|
tx: DbOrTx,
|
|
userId: string,
|
|
organizationId: string
|
|
): Promise<void> {
|
|
await tx.execute(
|
|
sql`select set_config('lock_timeout', ${`${ORG_MEMBERSHIP_LOCK_TIMEOUT_MS}ms`}, true)`
|
|
)
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(hashtextextended(${`${userId}:${organizationId}`}, 0))`
|
|
)
|
|
}
|
|
|
|
export type BillingBlockReason = 'payment_failed' | 'dispute'
|
|
|
|
/**
|
|
* Get all member user IDs for an organization
|
|
*/
|
|
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
|
|
const members = await db
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(eq(member.organizationId, organizationId))
|
|
|
|
return members.map((m) => m.userId)
|
|
}
|
|
|
|
/**
|
|
* Block all members of an organization for billing reasons
|
|
* Returns the number of members actually blocked
|
|
*
|
|
* Reason priority: dispute > payment_failed
|
|
* A payment_failed block won't overwrite an existing dispute block
|
|
*/
|
|
export async function blockOrgMembers(
|
|
organizationId: string,
|
|
reason: BillingBlockReason
|
|
): Promise<number> {
|
|
const memberIds = await getOrgMemberIds(organizationId)
|
|
|
|
if (memberIds.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
|
|
const whereClause =
|
|
reason === 'payment_failed'
|
|
? and(
|
|
inArray(userStats.userId, memberIds),
|
|
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
|
|
)
|
|
: inArray(userStats.userId, memberIds)
|
|
|
|
const result = await db
|
|
.update(userStats)
|
|
.set({ billingBlocked: true, billingBlockedReason: reason })
|
|
.where(whereClause)
|
|
.returning({ userId: userStats.userId })
|
|
|
|
return result.length
|
|
}
|
|
|
|
/**
|
|
* Unblock all members of an organization blocked for a specific reason
|
|
* Only unblocks members blocked for the specified reason (not other reasons)
|
|
* Returns the number of members actually unblocked
|
|
*/
|
|
export async function unblockOrgMembers(
|
|
organizationId: string,
|
|
reason: BillingBlockReason
|
|
): Promise<number> {
|
|
const memberIds = await getOrgMemberIds(organizationId)
|
|
|
|
if (memberIds.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
const result = await db
|
|
.update(userStats)
|
|
.set({ billingBlocked: false, billingBlockedReason: null })
|
|
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
|
|
.returning({ userId: userStats.userId })
|
|
|
|
return result.length
|
|
}
|
|
|
|
export interface RestoreProResult {
|
|
restored: boolean
|
|
usageRestored: boolean
|
|
subscriptionId?: string
|
|
}
|
|
|
|
/**
|
|
* Restore a user's personal Pro subscription if it was paused
|
|
* (`cancelAtPeriodEnd = true`) and merge any snapshotted Pro usage back
|
|
* into their current-period usage.
|
|
*
|
|
* All DB mutations run inside a single transaction so partial progress
|
|
* cannot be committed: either both the subscription un-pause and the
|
|
* usage snapshot merge succeed, or neither does. Errors propagate to
|
|
* the caller so webhook handlers can rely on Stripe retry semantics.
|
|
*
|
|
* Idempotent:
|
|
* - Early returns when the user has no paused Pro subscription, so
|
|
* re-runs after a successful restore are no-ops.
|
|
* - The snapshot merge only runs when `proPeriodCostSnapshot > 0`,
|
|
* so a second call after a prior success (which zeroes the
|
|
* snapshot) does nothing.
|
|
*
|
|
* Called when:
|
|
* - A member leaves a team (via `removeUserFromOrganization`).
|
|
* - A team subscription ends (members stay but get Pro restored).
|
|
*/
|
|
export async function restoreUserProSubscription(userId: string): Promise<RestoreProResult> {
|
|
const result: RestoreProResult = {
|
|
restored: false,
|
|
usageRestored: false,
|
|
}
|
|
|
|
const [personalPro] = await db
|
|
.select()
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, userId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
|
|
sqlIsPro(subscriptionTable.plan)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!personalPro?.cancelAtPeriodEnd || !personalPro.stripeSubscriptionId) {
|
|
return result
|
|
}
|
|
|
|
result.subscriptionId = personalPro.id
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(subscriptionTable)
|
|
.set({ cancelAtPeriodEnd: false })
|
|
.where(eq(subscriptionTable.id, personalPro.id))
|
|
|
|
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
|
|
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
|
subscriptionId: personalPro.id,
|
|
reason: 'member-left-paid-org',
|
|
})
|
|
|
|
result.restored = true
|
|
|
|
const [stats] = await tx
|
|
.select({
|
|
currentPeriodCost: userStats.currentPeriodCost,
|
|
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
|
})
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, userId))
|
|
.limit(1)
|
|
|
|
if (!stats) {
|
|
return
|
|
}
|
|
|
|
const currentNum = toNumber(toDecimal(stats.currentPeriodCost))
|
|
const snapshotNum = toNumber(toDecimal(stats.proPeriodCostSnapshot))
|
|
|
|
if (snapshotNum <= 0) {
|
|
return
|
|
}
|
|
|
|
const restoredUsage = (currentNum + snapshotNum).toString()
|
|
|
|
await tx
|
|
.update(userStats)
|
|
.set({
|
|
currentPeriodCost: restoredUsage,
|
|
proPeriodCostSnapshot: '0',
|
|
proPeriodCostSnapshotAt: null,
|
|
})
|
|
.where(eq(userStats.userId, userId))
|
|
|
|
result.usageRestored = true
|
|
|
|
logger.info('Restored Pro usage snapshot', {
|
|
userId,
|
|
previousUsage: currentNum,
|
|
snapshotUsage: snapshotNum,
|
|
restoredUsage,
|
|
})
|
|
})
|
|
|
|
logger.info('Restored personal Pro subscription (DB committed, Stripe queued)', {
|
|
userId,
|
|
subscriptionId: personalPro.id,
|
|
usageRestored: result.usageRestored,
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
export interface AddMemberParams {
|
|
userId: string
|
|
organizationId: string
|
|
role: 'admin' | 'member' | 'owner'
|
|
/** Skip Pro snapshot/cancellation logic (default: false) */
|
|
skipBillingLogic?: boolean
|
|
/** Skip seat validation (default: false) */
|
|
skipSeatValidation?: boolean
|
|
/** When provided, the acceptor's own pending invitation is excluded from the seat count during validation. */
|
|
acceptingInvitationId?: string
|
|
}
|
|
|
|
export interface AddMemberResult {
|
|
success: boolean
|
|
memberId?: string
|
|
error?: string
|
|
failureCode?: MembershipAdditionFailureCode
|
|
billingActions: {
|
|
proUsageSnapshotted: boolean
|
|
/**
|
|
* True when this function marked the user's personal Pro for
|
|
* cancellation at period end AND enqueued the Stripe sync via
|
|
* the outbox. Callers should NOT make a Stripe call themselves.
|
|
*/
|
|
proCancelledAtPeriodEnd: boolean
|
|
}
|
|
}
|
|
|
|
export interface EnsureMemberResult extends AddMemberResult {
|
|
alreadyMember: boolean
|
|
existingOrgId?: string
|
|
}
|
|
|
|
export interface RemoveMemberParams {
|
|
userId: string
|
|
organizationId: string
|
|
memberId: string
|
|
/** Skip departed usage capture and Pro restoration (default: false) */
|
|
skipBillingLogic?: boolean
|
|
/**
|
|
* Only remove the member when they hold no remaining permission on any of the
|
|
* org's workspaces, evaluated atomically under the membership lock. Used by
|
|
* the workspace-removal path so a concurrent invite acceptance can't be raced
|
|
* into a "workspace access but no membership" state. When access remains, the
|
|
* member is kept and the result has `removed: false`.
|
|
*/
|
|
requireNoOrgWorkspaceAccess?: boolean
|
|
}
|
|
|
|
export interface RemoveMemberResult {
|
|
success: boolean
|
|
error?: string
|
|
/**
|
|
* Whether the member row was actually deleted. `false` (with `success: true`)
|
|
* when `requireNoOrgWorkspaceAccess` was set and the user still had workspace
|
|
* access, so the membership was intentionally left in place.
|
|
*/
|
|
removed?: boolean
|
|
billingActions: {
|
|
usageCaptured: number
|
|
proRestored: boolean
|
|
usageRestored: boolean
|
|
workspaceAccessRevoked: number
|
|
pendingInvitationsCancelled: number
|
|
}
|
|
}
|
|
|
|
export interface RemoveExternalWorkspaceAccessResult {
|
|
success: boolean
|
|
error?: string
|
|
workspaceAccessRevoked: number
|
|
permissionGroupsRevoked: number
|
|
credentialMembershipsRevoked: number
|
|
pendingInvitationsCancelled: number
|
|
}
|
|
|
|
export type MembershipAdditionFailureCode =
|
|
| 'user-not-found'
|
|
| 'organization-not-found'
|
|
| 'already-member'
|
|
| 'already-in-other-organization'
|
|
| 'no-seats-available'
|
|
|
|
async function reassignOwnedOrganizationWorkspacesTx({
|
|
tx,
|
|
userId,
|
|
organizationId,
|
|
workspaceIds,
|
|
}: {
|
|
tx: DbOrTx
|
|
userId: string
|
|
organizationId: string
|
|
workspaceIds: string[]
|
|
}) {
|
|
if (workspaceIds.length === 0) return 0
|
|
|
|
const [ownerMembership] = await tx
|
|
.select({ userId: member.userId })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
|
|
.limit(1)
|
|
|
|
const ownerId = ownerMembership?.userId
|
|
if (!ownerId || ownerId === userId) return 0
|
|
|
|
const reassignedWorkspaces = await tx
|
|
.update(workspace)
|
|
.set({ ownerId, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(workspace.organizationId, organizationId),
|
|
eq(workspace.ownerId, userId),
|
|
inArray(workspace.id, workspaceIds)
|
|
)
|
|
)
|
|
.returning({
|
|
id: workspace.id,
|
|
})
|
|
|
|
if (reassignedWorkspaces.length === 0) {
|
|
return 0
|
|
}
|
|
|
|
const now = new Date()
|
|
await tx
|
|
.insert(permissions)
|
|
.values(
|
|
reassignedWorkspaces.map((row) => ({
|
|
id: generateId(),
|
|
userId: ownerId,
|
|
entityType: 'workspace',
|
|
entityId: row.id,
|
|
permissionType: 'admin' as const,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}))
|
|
)
|
|
.onConflictDoUpdate({
|
|
target: [permissions.userId, permissions.entityType, permissions.entityId],
|
|
set: { permissionType: 'admin', updatedAt: now },
|
|
})
|
|
|
|
return reassignedWorkspaces.length
|
|
}
|
|
|
|
interface MembershipValidationResult {
|
|
canAdd: boolean
|
|
reason?: string
|
|
failureCode?: MembershipAdditionFailureCode
|
|
existingOrgId?: string
|
|
seatValidation?: {
|
|
currentSeats: number
|
|
maxSeats: number
|
|
availableSeats: number
|
|
}
|
|
}
|
|
|
|
export async function ensureUserInOrganization(
|
|
params: AddMemberParams
|
|
): Promise<EnsureMemberResult> {
|
|
const existingMembership = await getUserOrganization(params.userId)
|
|
|
|
if (existingMembership?.organizationId === params.organizationId) {
|
|
return {
|
|
success: true,
|
|
memberId: existingMembership.memberId,
|
|
alreadyMember: true,
|
|
billingActions: {
|
|
proUsageSnapshotted: false,
|
|
proCancelledAtPeriodEnd: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (existingMembership) {
|
|
return {
|
|
success: false,
|
|
alreadyMember: false,
|
|
existingOrgId: existingMembership.organizationId,
|
|
failureCode: 'already-in-other-organization',
|
|
error:
|
|
'User is already a member of another organization. Users can only belong to one organization at a time.',
|
|
billingActions: {
|
|
proUsageSnapshotted: false,
|
|
proCancelledAtPeriodEnd: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
const result = await addUserToOrganization(params)
|
|
|
|
return {
|
|
...result,
|
|
alreadyMember: false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate if a user can be added to an organization.
|
|
* Checks single-org constraint and seat availability.
|
|
*/
|
|
async function validateMembershipAddition(
|
|
userId: string,
|
|
organizationId: string,
|
|
options: { acceptingInvitationId?: string } = {}
|
|
): Promise<MembershipValidationResult> {
|
|
const [userData] = await db.select({ id: user.id }).from(user).where(eq(user.id, userId)).limit(1)
|
|
|
|
if (!userData) {
|
|
return { canAdd: false, reason: 'User not found', failureCode: 'user-not-found' }
|
|
}
|
|
|
|
const [orgData] = await db
|
|
.select({ id: organization.id })
|
|
.from(organization)
|
|
.where(eq(organization.id, organizationId))
|
|
.limit(1)
|
|
|
|
if (!orgData) {
|
|
return {
|
|
canAdd: false,
|
|
reason: 'Organization not found',
|
|
failureCode: 'organization-not-found',
|
|
}
|
|
}
|
|
|
|
const existingMemberships = await db
|
|
.select({ organizationId: member.organizationId })
|
|
.from(member)
|
|
.where(eq(member.userId, userId))
|
|
|
|
if (existingMemberships.length > 0) {
|
|
const isAlreadyMemberOfThisOrg = existingMemberships.some(
|
|
(m) => m.organizationId === organizationId
|
|
)
|
|
|
|
if (isAlreadyMemberOfThisOrg) {
|
|
return {
|
|
canAdd: false,
|
|
reason: 'User is already a member of this organization',
|
|
failureCode: 'already-member',
|
|
}
|
|
}
|
|
|
|
return {
|
|
canAdd: false,
|
|
reason:
|
|
'User is already a member of another organization. Users can only belong to one organization at a time.',
|
|
failureCode: 'already-in-other-organization',
|
|
existingOrgId: existingMemberships[0].organizationId,
|
|
}
|
|
}
|
|
|
|
const seatValidation = await validateSeatAvailability(organizationId, 1, {
|
|
excludePendingInvitationId: options.acceptingInvitationId,
|
|
})
|
|
if (!seatValidation.canInvite) {
|
|
return {
|
|
canAdd: false,
|
|
reason: seatValidation.reason || 'No seats available',
|
|
failureCode: 'no-seats-available',
|
|
seatValidation: {
|
|
currentSeats: seatValidation.currentSeats,
|
|
maxSeats: seatValidation.maxSeats,
|
|
availableSeats: seatValidation.availableSeats,
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
canAdd: true,
|
|
seatValidation: {
|
|
currentSeats: seatValidation.currentSeats,
|
|
maxSeats: seatValidation.maxSeats,
|
|
availableSeats: seatValidation.availableSeats,
|
|
},
|
|
}
|
|
}
|
|
|
|
interface PaidOrgJoinBillingActions {
|
|
proUsageSnapshotted: boolean
|
|
proCancelledAtPeriodEnd: boolean
|
|
}
|
|
|
|
/**
|
|
* Applies the billing side-effects of a user joining a paid (Team/Enterprise)
|
|
* organization inside an existing transaction:
|
|
* - snapshots current Pro usage so new usage attributes to the org;
|
|
* - marks personal Pro subscription `cancelAtPeriodEnd=true` and enqueues
|
|
* the Stripe sync via the outbox;
|
|
* - transfers personal storage bytes into the org's pool.
|
|
*
|
|
* Idempotent: re-running is a no-op when Pro is already flagged cancel-at-period-end
|
|
* and the user's storage is already transferred (zeroed).
|
|
*/
|
|
async function applyPaidOrgJoinBillingTx(
|
|
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
|
userId: string,
|
|
organizationId: string
|
|
): Promise<PaidOrgJoinBillingActions> {
|
|
const actions: PaidOrgJoinBillingActions = {
|
|
proUsageSnapshotted: false,
|
|
proCancelledAtPeriodEnd: false,
|
|
}
|
|
|
|
const [personalPro] = await tx
|
|
.select()
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, userId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
|
|
sqlIsPro(subscriptionTable.plan)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (personalPro && !personalPro.cancelAtPeriodEnd) {
|
|
// Lock the personal Pro subscription before userStats (snapshotted below) so
|
|
// this matches restoreUserProSubscription's subscription → userStats order
|
|
// and cannot deadlock against a concurrent Pro restore for the same user.
|
|
// The cancel update further down re-locks this row (no-op).
|
|
await tx
|
|
.select({ id: subscriptionTable.id })
|
|
.from(subscriptionTable)
|
|
.where(eq(subscriptionTable.id, personalPro.id))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
const [userStatsRow] = await tx
|
|
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, userId))
|
|
.limit(1)
|
|
|
|
if (userStatsRow) {
|
|
const currentProUsage = userStatsRow.currentPeriodCost || '0'
|
|
|
|
await tx
|
|
.update(userStats)
|
|
.set({
|
|
proPeriodCostSnapshot: currentProUsage,
|
|
proPeriodCostSnapshotAt: new Date(),
|
|
currentPeriodCost: '0',
|
|
currentPeriodCopilotCost: '0',
|
|
})
|
|
.where(eq(userStats.userId, userId))
|
|
|
|
actions.proUsageSnapshotted = true
|
|
|
|
logger.info('Snapshotted Pro usage when joining paid org', {
|
|
userId,
|
|
proUsageSnapshot: currentProUsage,
|
|
organizationId,
|
|
})
|
|
}
|
|
|
|
await tx
|
|
.update(subscriptionTable)
|
|
.set({ cancelAtPeriodEnd: true })
|
|
.where(eq(subscriptionTable.id, personalPro.id))
|
|
|
|
if (personalPro.stripeSubscriptionId) {
|
|
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
|
|
stripeSubscriptionId: personalPro.stripeSubscriptionId,
|
|
subscriptionId: personalPro.id,
|
|
reason: 'joined-paid-org',
|
|
})
|
|
}
|
|
|
|
actions.proCancelledAtPeriodEnd = true
|
|
|
|
logger.info('Marked personal Pro for cancellation at period end (Stripe queued)', {
|
|
userId,
|
|
subscriptionId: personalPro.id,
|
|
organizationId,
|
|
})
|
|
}
|
|
|
|
const storageRows = await tx
|
|
.select({ storageUsedBytes: userStats.storageUsedBytes })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, userId))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
const bytesToTransfer = storageRows[0]?.storageUsedBytes ?? 0
|
|
if (bytesToTransfer > 0) {
|
|
await tx
|
|
.update(organization)
|
|
.set({
|
|
storageUsedBytes: sql`${organization.storageUsedBytes} + ${bytesToTransfer}`,
|
|
})
|
|
.where(eq(organization.id, organizationId))
|
|
|
|
await tx.update(userStats).set({ storageUsedBytes: 0 }).where(eq(userStats.userId, userId))
|
|
|
|
logger.info('Transferred personal storage bytes to org pool on join', {
|
|
userId,
|
|
organizationId,
|
|
bytes: bytesToTransfer,
|
|
})
|
|
}
|
|
|
|
return actions
|
|
}
|
|
|
|
/**
|
|
* Re-applies paid-org join billing for a user who is already a member of
|
|
* the organization. Used on re-upgrade after a dormant transition: members
|
|
* kept their org membership but had their personal Pro subscriptions
|
|
* restored (`cancelAtPeriodEnd=false`) during the cancel/downgrade. When
|
|
* the org becomes paid again, those Pros must be re-paused so the user
|
|
* isn't double-billed.
|
|
*
|
|
* No-op when the org has no active Team/Enterprise subscription.
|
|
*/
|
|
export async function reapplyPaidOrgJoinBillingForExistingMember(
|
|
userId: string,
|
|
organizationId: string
|
|
): Promise<PaidOrgJoinBillingActions> {
|
|
return db.transaction(async (tx) => {
|
|
const [orgSub] = await tx
|
|
.select({ plan: subscriptionTable.plan })
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, organizationId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!orgSub || !isPaid(orgSub.plan)) {
|
|
return { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false }
|
|
}
|
|
|
|
return applyPaidOrgJoinBillingTx(tx, userId, organizationId)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Add a user to an organization with full billing logic.
|
|
*
|
|
* Handles:
|
|
* - Single organization constraint validation
|
|
* - Seat availability validation
|
|
* - Member record creation
|
|
* - Pro usage snapshot when joining paid team
|
|
* - Pro subscription cancellation at period end
|
|
* - Usage limit sync
|
|
*/
|
|
export async function addUserToOrganization(params: AddMemberParams): Promise<AddMemberResult> {
|
|
const {
|
|
userId,
|
|
organizationId,
|
|
role,
|
|
skipBillingLogic = false,
|
|
skipSeatValidation = false,
|
|
acceptingInvitationId,
|
|
} = params
|
|
|
|
const billingActions: AddMemberResult['billingActions'] = {
|
|
proUsageSnapshotted: false,
|
|
proCancelledAtPeriodEnd: false,
|
|
}
|
|
|
|
try {
|
|
if (!skipSeatValidation) {
|
|
const validation = await validateMembershipAddition(userId, organizationId, {
|
|
acceptingInvitationId,
|
|
})
|
|
if (!validation.canAdd) {
|
|
return {
|
|
success: false,
|
|
error: validation.reason,
|
|
failureCode: validation.failureCode,
|
|
billingActions,
|
|
}
|
|
}
|
|
} else {
|
|
const existingMemberships = await db
|
|
.select({ organizationId: member.organizationId })
|
|
.from(member)
|
|
.where(eq(member.userId, userId))
|
|
|
|
if (existingMemberships.length > 0) {
|
|
const isAlreadyMemberOfThisOrg = existingMemberships.some(
|
|
(m) => m.organizationId === organizationId
|
|
)
|
|
|
|
if (isAlreadyMemberOfThisOrg) {
|
|
return {
|
|
success: false,
|
|
error: 'User is already a member of this organization',
|
|
failureCode: 'already-member',
|
|
billingActions,
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
'User is already a member of another organization. Users can only belong to one organization at a time.',
|
|
failureCode: 'already-in-other-organization',
|
|
billingActions,
|
|
}
|
|
}
|
|
}
|
|
|
|
let memberId = ''
|
|
|
|
await db.transaction(async (tx) => {
|
|
memberId = generateId()
|
|
await tx.insert(member).values({
|
|
id: memberId,
|
|
userId,
|
|
organizationId,
|
|
role,
|
|
createdAt: new Date(),
|
|
})
|
|
|
|
if (skipBillingLogic) {
|
|
return
|
|
}
|
|
|
|
const [orgSub] = await tx
|
|
.select({ plan: subscriptionTable.plan })
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, organizationId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!orgSub || !isPaid(orgSub.plan)) {
|
|
return
|
|
}
|
|
|
|
const joinBillingActions = await applyPaidOrgJoinBillingTx(tx, userId, organizationId)
|
|
billingActions.proUsageSnapshotted = joinBillingActions.proUsageSnapshotted
|
|
billingActions.proCancelledAtPeriodEnd = joinBillingActions.proCancelledAtPeriodEnd
|
|
})
|
|
|
|
logger.info('Added user to organization', {
|
|
userId,
|
|
organizationId,
|
|
role,
|
|
memberId,
|
|
billingActions,
|
|
})
|
|
|
|
return { success: true, memberId, billingActions }
|
|
} catch (error) {
|
|
logger.error('Failed to add user to organization', { userId, organizationId, error })
|
|
return { success: false, error: 'Failed to add user to organization', billingActions }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a user from an organization with full billing logic.
|
|
*
|
|
* Handles:
|
|
* - Owner removal prevention
|
|
* - Departed member usage capture
|
|
* - Member record deletion
|
|
* - Pro subscription restoration when leaving a paid team
|
|
* - Pro usage restoration from snapshot
|
|
*
|
|
* Note: Users can only belong to one organization at a time.
|
|
*/
|
|
export async function removeUserFromOrganization(
|
|
params: RemoveMemberParams
|
|
): Promise<RemoveMemberResult> {
|
|
const {
|
|
userId,
|
|
organizationId,
|
|
memberId,
|
|
skipBillingLogic = false,
|
|
requireNoOrgWorkspaceAccess = false,
|
|
} = params
|
|
|
|
const billingActions = {
|
|
usageCaptured: 0,
|
|
proRestored: false,
|
|
usageRestored: false,
|
|
workspaceAccessRevoked: 0,
|
|
pendingInvitationsCancelled: 0,
|
|
}
|
|
|
|
try {
|
|
const [existingMember] = await db
|
|
.select({
|
|
id: member.id,
|
|
userId: member.userId,
|
|
role: member.role,
|
|
})
|
|
.from(member)
|
|
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
|
|
.limit(1)
|
|
|
|
if (!existingMember) {
|
|
return { success: false, error: 'Member not found', billingActions }
|
|
}
|
|
|
|
if (existingMember.role === 'owner') {
|
|
return { success: false, error: 'Cannot remove organization owner', billingActions }
|
|
}
|
|
|
|
const result = await db.transaction(async (tx) => {
|
|
/**
|
|
* Serialize against a concurrent invite acceptance for this user+org so
|
|
* the remaining-access check below and the deletions are atomic with the
|
|
* accept's membership re-check + workspace grant — preventing a
|
|
* "workspace access but no membership" (or the inverse) interleaving.
|
|
*/
|
|
await acquireOrgMembershipLock(tx, userId, organizationId)
|
|
|
|
const orgWorkspaces = await tx
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(eq(workspace.organizationId, organizationId))
|
|
const workspaceIds = orgWorkspaces.map((w) => w.id)
|
|
|
|
if (requireNoOrgWorkspaceAccess && workspaceIds.length > 0) {
|
|
const [remainingAccess] = await tx
|
|
.select({ id: permissions.id })
|
|
.from(permissions)
|
|
.where(
|
|
and(
|
|
eq(permissions.userId, userId),
|
|
eq(permissions.entityType, 'workspace'),
|
|
inArray(permissions.entityId, workspaceIds)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (remainingAccess) {
|
|
return { skipped: true as const }
|
|
}
|
|
}
|
|
|
|
const deletedMember = await tx
|
|
.delete(member)
|
|
.where(and(eq(member.id, memberId), ne(member.role, 'owner')))
|
|
.returning({ id: member.id })
|
|
|
|
if (deletedMember.length === 0) {
|
|
throw new Error(
|
|
'Member could not be removed — they may have been promoted to owner concurrently'
|
|
)
|
|
}
|
|
|
|
const [targetUser] = await tx
|
|
.select({ email: user.email })
|
|
.from(user)
|
|
.where(eq(user.id, userId))
|
|
.limit(1)
|
|
|
|
const cancelledInvitations = targetUser?.email
|
|
? await tx
|
|
.update(invitation)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
sql`lower(${invitation.email}) = lower(${targetUser.email})`
|
|
)
|
|
)
|
|
.returning({ id: invitation.id })
|
|
: []
|
|
|
|
const captureDepartedUsage = async () => {
|
|
if (skipBillingLogic) return 0
|
|
|
|
const [departingUserStats] = await tx
|
|
.select({ currentPeriodCost: userStats.currentPeriodCost })
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, userId))
|
|
.for('update')
|
|
.limit(1)
|
|
|
|
const usage = toNumber(toDecimal(departingUserStats?.currentPeriodCost))
|
|
if (usage <= 0) return 0
|
|
|
|
await tx
|
|
.update(organization)
|
|
.set({
|
|
departedMemberUsage: sql`${organization.departedMemberUsage} + ${usage}`,
|
|
})
|
|
.where(eq(organization.id, organizationId))
|
|
|
|
await tx
|
|
.update(userStats)
|
|
.set({ currentPeriodCost: '0' })
|
|
.where(eq(userStats.userId, userId))
|
|
|
|
return usage
|
|
}
|
|
|
|
// Permission groups are organization-scoped, so a departing member's group
|
|
// membership must be cleared whenever they leave the org — including the
|
|
// zero-workspace early return below (a group can exist with members but no
|
|
// workspaces).
|
|
await tx
|
|
.delete(permissionGroupMember)
|
|
.where(
|
|
and(
|
|
eq(permissionGroupMember.userId, userId),
|
|
eq(permissionGroupMember.organizationId, organizationId)
|
|
)
|
|
)
|
|
|
|
if (workspaceIds.length === 0) {
|
|
const capturedUsage = await captureDepartedUsage()
|
|
|
|
return {
|
|
skipped: false as const,
|
|
workspaceIdsToRevoke: [] as string[],
|
|
usageCaptured: capturedUsage,
|
|
credentialMembershipsRevoked: 0,
|
|
pendingInvitationsCancelled: cancelledInvitations.length,
|
|
}
|
|
}
|
|
|
|
await reassignOwnedOrganizationWorkspacesTx({
|
|
tx,
|
|
userId,
|
|
organizationId,
|
|
workspaceIds,
|
|
})
|
|
|
|
const workflowOwnershipReassignment =
|
|
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
|
tx,
|
|
workspaceIds,
|
|
departingUserId: userId,
|
|
})
|
|
if (workflowOwnershipReassignment.unresolved.length > 0) {
|
|
throw new WorkspaceBillingAccountRemovalError()
|
|
}
|
|
|
|
const deletedPerms = await tx
|
|
.delete(permissions)
|
|
.where(
|
|
and(
|
|
eq(permissions.userId, userId),
|
|
eq(permissions.entityType, 'workspace'),
|
|
inArray(permissions.entityId, workspaceIds)
|
|
)
|
|
)
|
|
.returning({ entityId: permissions.entityId })
|
|
|
|
const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx(
|
|
tx,
|
|
workspaceIds,
|
|
userId
|
|
)
|
|
const capturedUsage = await captureDepartedUsage()
|
|
|
|
return {
|
|
skipped: false as const,
|
|
workspaceIdsToRevoke: deletedPerms.map((row) => row.entityId),
|
|
usageCaptured: capturedUsage,
|
|
credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled: cancelledInvitations.length,
|
|
}
|
|
})
|
|
|
|
if (result.skipped) {
|
|
logger.info('Skipped org removal: member still has workspace access', {
|
|
organizationId,
|
|
userId,
|
|
memberId,
|
|
})
|
|
return { success: true, removed: false, billingActions }
|
|
}
|
|
|
|
billingActions.usageCaptured = result.usageCaptured
|
|
billingActions.workspaceAccessRevoked = result.workspaceIdsToRevoke.length
|
|
billingActions.pendingInvitationsCancelled = result.pendingInvitationsCancelled
|
|
|
|
if (result.usageCaptured > 0) {
|
|
logger.info('Captured departed member usage', {
|
|
organizationId,
|
|
userId,
|
|
usage: result.usageCaptured,
|
|
})
|
|
}
|
|
|
|
logger.info('Removed member from organization', {
|
|
organizationId,
|
|
userId,
|
|
memberId,
|
|
workspaceAccessRevoked: result.workspaceIdsToRevoke.length,
|
|
credentialMembershipsRevoked: result.credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled: result.pendingInvitationsCancelled,
|
|
})
|
|
|
|
if (!skipBillingLogic) {
|
|
try {
|
|
const remainingPaidTeams = await db
|
|
.select({ orgId: member.organizationId })
|
|
.from(member)
|
|
.where(eq(member.userId, userId))
|
|
|
|
let hasAnyPaidTeam = false
|
|
if (remainingPaidTeams.length > 0) {
|
|
const orgIds = remainingPaidTeams.map((m) => m.orgId)
|
|
const orgPaidSubs = await db
|
|
.select()
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
inArray(subscriptionTable.referenceId, orgIds),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
|
|
hasAnyPaidTeam = orgPaidSubs.some((s) => isPaid(s.plan))
|
|
}
|
|
|
|
if (!hasAnyPaidTeam) {
|
|
const restoreResult = await restoreUserProSubscription(userId)
|
|
billingActions.proRestored = restoreResult.restored
|
|
billingActions.usageRestored = restoreResult.usageRestored
|
|
|
|
await syncUsageLimitsFromSubscription(userId)
|
|
}
|
|
} catch (postRemoveError) {
|
|
logger.error('Post-removal personal Pro restore check failed', {
|
|
organizationId,
|
|
userId,
|
|
error: postRemoveError,
|
|
})
|
|
}
|
|
}
|
|
|
|
return { success: true, removed: true, billingActions }
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceBillingAccountRemovalError) {
|
|
return { success: false, error: error.message, billingActions }
|
|
}
|
|
|
|
logger.error('Failed to remove user from organization', {
|
|
userId,
|
|
organizationId,
|
|
memberId,
|
|
error,
|
|
})
|
|
return { success: false, error: 'Failed to remove user from organization', billingActions }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes a non-member's access from every workspace owned by an organization.
|
|
* External workspace members have workspace permissions but no organization member row.
|
|
*/
|
|
export async function removeExternalUserFromOrganizationWorkspaces(params: {
|
|
userId: string
|
|
organizationId: string
|
|
}): Promise<RemoveExternalWorkspaceAccessResult> {
|
|
const { userId, organizationId } = params
|
|
|
|
try {
|
|
const [existingMember] = await db
|
|
.select({ id: member.id })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
|
|
.limit(1)
|
|
|
|
if (existingMember) {
|
|
return {
|
|
success: false,
|
|
error: 'User is an organization member',
|
|
workspaceAccessRevoked: 0,
|
|
permissionGroupsRevoked: 0,
|
|
credentialMembershipsRevoked: 0,
|
|
pendingInvitationsCancelled: 0,
|
|
}
|
|
}
|
|
|
|
const {
|
|
workspaceAccessRevoked,
|
|
permissionGroupsRevoked,
|
|
credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled,
|
|
} = await db.transaction(async (tx) => {
|
|
const orgWorkspaces = await tx
|
|
.select({ id: workspace.id })
|
|
.from(workspace)
|
|
.where(eq(workspace.organizationId, organizationId))
|
|
|
|
if (orgWorkspaces.length === 0) {
|
|
return {
|
|
workspaceAccessRevoked: 0,
|
|
permissionGroupsRevoked: 0,
|
|
credentialMembershipsRevoked: 0,
|
|
pendingInvitationsCancelled: 0,
|
|
}
|
|
}
|
|
|
|
const workspaceIds = orgWorkspaces.map((w) => w.id)
|
|
const [targetUser] = await tx
|
|
.select({ email: user.email })
|
|
.from(user)
|
|
.where(eq(user.id, userId))
|
|
.limit(1)
|
|
|
|
await reassignOwnedOrganizationWorkspacesTx({
|
|
tx,
|
|
userId,
|
|
organizationId,
|
|
workspaceIds,
|
|
})
|
|
|
|
const workflowOwnershipReassignment =
|
|
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
|
tx,
|
|
workspaceIds,
|
|
departingUserId: userId,
|
|
})
|
|
if (workflowOwnershipReassignment.unresolved.length > 0) {
|
|
throw new WorkspaceBillingAccountRemovalError()
|
|
}
|
|
|
|
const deletedPermissions = await tx
|
|
.delete(permissions)
|
|
.where(
|
|
and(
|
|
eq(permissions.userId, userId),
|
|
eq(permissions.entityType, 'workspace'),
|
|
inArray(permissions.entityId, workspaceIds)
|
|
)
|
|
)
|
|
.returning({ entityId: permissions.entityId })
|
|
|
|
const deletedPermissionGroups = await tx
|
|
.delete(permissionGroupMember)
|
|
.where(
|
|
and(
|
|
eq(permissionGroupMember.userId, userId),
|
|
eq(permissionGroupMember.organizationId, organizationId)
|
|
)
|
|
)
|
|
.returning({ id: permissionGroupMember.id })
|
|
|
|
const credentialMembershipsRevoked = await revokeWorkspaceCredentialMembershipsTx(
|
|
tx,
|
|
workspaceIds,
|
|
userId
|
|
)
|
|
|
|
const cancelledInvitations = targetUser?.email
|
|
? await tx
|
|
.update(invitation)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
eq(invitation.membershipIntent, 'external'),
|
|
sql`lower(${invitation.email}) = lower(${targetUser.email})`
|
|
)
|
|
)
|
|
.returning({ id: invitation.id })
|
|
: []
|
|
|
|
return {
|
|
workspaceAccessRevoked: deletedPermissions.length,
|
|
permissionGroupsRevoked: deletedPermissionGroups.length,
|
|
credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled: cancelledInvitations.length,
|
|
}
|
|
})
|
|
|
|
if (
|
|
workspaceAccessRevoked === 0 &&
|
|
permissionGroupsRevoked === 0 &&
|
|
credentialMembershipsRevoked === 0 &&
|
|
pendingInvitationsCancelled === 0
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: 'External workspace member not found',
|
|
workspaceAccessRevoked,
|
|
permissionGroupsRevoked,
|
|
credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled,
|
|
}
|
|
}
|
|
|
|
logger.info('Removed external workspace member from organization workspaces', {
|
|
organizationId,
|
|
userId,
|
|
workspaceAccessRevoked,
|
|
permissionGroupsRevoked,
|
|
credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled,
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
workspaceAccessRevoked,
|
|
permissionGroupsRevoked,
|
|
credentialMembershipsRevoked,
|
|
pendingInvitationsCancelled,
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceBillingAccountRemovalError) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
workspaceAccessRevoked: 0,
|
|
permissionGroupsRevoked: 0,
|
|
credentialMembershipsRevoked: 0,
|
|
pendingInvitationsCancelled: 0,
|
|
}
|
|
}
|
|
|
|
logger.error('Failed to remove external workspace member from organization workspaces', {
|
|
organizationId,
|
|
userId,
|
|
error,
|
|
})
|
|
return {
|
|
success: false,
|
|
error: 'Failed to remove external workspace member',
|
|
workspaceAccessRevoked: 0,
|
|
permissionGroupsRevoked: 0,
|
|
credentialMembershipsRevoked: 0,
|
|
pendingInvitationsCancelled: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface TransferOwnershipParams {
|
|
organizationId: string
|
|
currentOwnerUserId: string
|
|
newOwnerUserId: string
|
|
}
|
|
|
|
export interface TransferOwnershipResult {
|
|
success: boolean
|
|
error?: string
|
|
workspacesReassigned: number
|
|
billedAccountReassigned: number
|
|
overageMigrated: string
|
|
billingBlockInherited: boolean
|
|
}
|
|
|
|
export async function transferOrganizationOwnership(
|
|
params: TransferOwnershipParams
|
|
): Promise<TransferOwnershipResult> {
|
|
const { organizationId, currentOwnerUserId, newOwnerUserId } = params
|
|
|
|
const result: TransferOwnershipResult = {
|
|
success: false,
|
|
workspacesReassigned: 0,
|
|
billedAccountReassigned: 0,
|
|
overageMigrated: '0',
|
|
billingBlockInherited: false,
|
|
}
|
|
|
|
if (currentOwnerUserId === newOwnerUserId) {
|
|
return { ...result, success: false, error: 'New owner must differ from current owner' }
|
|
}
|
|
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
const [currentOwnerMember] = await tx
|
|
.select({ id: member.id, role: member.role })
|
|
.from(member)
|
|
.where(
|
|
and(
|
|
eq(member.organizationId, organizationId),
|
|
eq(member.userId, currentOwnerUserId),
|
|
eq(member.role, 'owner')
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!currentOwnerMember) {
|
|
throw new Error('Current user is not the owner of this organization')
|
|
}
|
|
|
|
const [newOwnerMember] = await tx
|
|
.select({ id: member.id, role: member.role })
|
|
.from(member)
|
|
.where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId)))
|
|
.limit(1)
|
|
|
|
if (!newOwnerMember) {
|
|
throw new Error('Target user is not a member of this organization')
|
|
}
|
|
|
|
await tx.update(member).set({ role: 'admin' }).where(eq(member.id, currentOwnerMember.id))
|
|
|
|
await tx.update(member).set({ role: 'owner' }).where(eq(member.id, newOwnerMember.id))
|
|
|
|
const billedUpdate = await tx
|
|
.update(workspace)
|
|
.set({ billedAccountUserId: newOwnerUserId })
|
|
.where(
|
|
and(
|
|
eq(workspace.organizationId, organizationId),
|
|
eq(workspace.billedAccountUserId, currentOwnerUserId)
|
|
)
|
|
)
|
|
.returning({ id: workspace.id })
|
|
|
|
result.billedAccountReassigned = billedUpdate.length
|
|
|
|
const ownerUpdate = await tx
|
|
.update(workspace)
|
|
.set({ ownerId: newOwnerUserId })
|
|
.where(
|
|
and(
|
|
eq(workspace.organizationId, organizationId),
|
|
eq(workspace.ownerId, currentOwnerUserId)
|
|
)
|
|
)
|
|
.returning({ id: workspace.id })
|
|
|
|
result.workspacesReassigned = ownerUpdate.length
|
|
|
|
const reassignedWorkspaceIds = Array.from(
|
|
new Set([...billedUpdate.map((w) => w.id), ...ownerUpdate.map((w) => w.id)])
|
|
)
|
|
|
|
if (reassignedWorkspaceIds.length > 0) {
|
|
const now = new Date()
|
|
await tx
|
|
.insert(permissions)
|
|
.values(
|
|
reassignedWorkspaceIds.map((workspaceId) => ({
|
|
id: generateId(),
|
|
userId: newOwnerUserId,
|
|
entityType: 'workspace' as const,
|
|
entityId: workspaceId,
|
|
permissionType: 'admin' as const,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}))
|
|
)
|
|
.onConflictDoUpdate({
|
|
target: [permissions.userId, permissions.entityType, permissions.entityId],
|
|
set: { permissionType: 'admin', updatedAt: now },
|
|
})
|
|
}
|
|
|
|
const [oldStats] = await tx
|
|
.select({
|
|
billedOverageThisPeriod: userStats.billedOverageThisPeriod,
|
|
billingBlocked: userStats.billingBlocked,
|
|
billingBlockedReason: userStats.billingBlockedReason,
|
|
})
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, currentOwnerUserId))
|
|
.limit(1)
|
|
|
|
if (oldStats) {
|
|
await tx
|
|
.insert(userStats)
|
|
.values({
|
|
id: generateId(),
|
|
userId: newOwnerUserId,
|
|
usageLimitUpdatedAt: new Date(),
|
|
})
|
|
.onConflictDoNothing({ target: userStats.userId })
|
|
|
|
const overage = oldStats.billedOverageThisPeriod || '0'
|
|
const overageNum = toNumber(toDecimal(overage))
|
|
if (overageNum > 0) {
|
|
await tx
|
|
.update(userStats)
|
|
.set({
|
|
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${overage}`,
|
|
})
|
|
.where(eq(userStats.userId, newOwnerUserId))
|
|
|
|
await tx
|
|
.update(userStats)
|
|
.set({ billedOverageThisPeriod: '0' })
|
|
.where(eq(userStats.userId, currentOwnerUserId))
|
|
|
|
result.overageMigrated = overage
|
|
}
|
|
|
|
if (oldStats.billingBlocked) {
|
|
const [newOwnerStats] = await tx
|
|
.select({
|
|
billingBlocked: userStats.billingBlocked,
|
|
billingBlockedReason: userStats.billingBlockedReason,
|
|
})
|
|
.from(userStats)
|
|
.where(eq(userStats.userId, newOwnerUserId))
|
|
.limit(1)
|
|
|
|
const newOwnerAlreadyBlocked = !!newOwnerStats?.billingBlocked
|
|
const newOwnerReason = newOwnerStats?.billingBlockedReason ?? null
|
|
const inheritedReason = oldStats.billingBlockedReason
|
|
|
|
const shouldUpgradeReason =
|
|
!newOwnerAlreadyBlocked ||
|
|
(newOwnerReason === 'payment_failed' && inheritedReason === 'dispute')
|
|
|
|
if (!newOwnerAlreadyBlocked) {
|
|
await tx
|
|
.update(userStats)
|
|
.set({
|
|
billingBlocked: true,
|
|
billingBlockedReason: inheritedReason,
|
|
})
|
|
.where(eq(userStats.userId, newOwnerUserId))
|
|
result.billingBlockInherited = true
|
|
} else if (shouldUpgradeReason) {
|
|
await tx
|
|
.update(userStats)
|
|
.set({ billingBlockedReason: inheritedReason })
|
|
.where(eq(userStats.userId, newOwnerUserId))
|
|
result.billingBlockInherited = true
|
|
}
|
|
}
|
|
}
|
|
|
|
const [orgSub] = await tx
|
|
.select({
|
|
id: subscriptionTable.id,
|
|
stripeCustomerId: subscriptionTable.stripeCustomerId,
|
|
})
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, organizationId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (orgSub?.stripeCustomerId) {
|
|
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CUSTOMER_CONTACT, {
|
|
subscriptionId: orgSub.id,
|
|
reason: 'ownership-transfer',
|
|
})
|
|
}
|
|
})
|
|
|
|
logger.info('Transferred organization ownership', {
|
|
organizationId,
|
|
currentOwnerUserId,
|
|
newOwnerUserId,
|
|
workspacesReassigned: result.workspacesReassigned,
|
|
billedAccountReassigned: result.billedAccountReassigned,
|
|
overageMigrated: result.overageMigrated,
|
|
billingBlockInherited: result.billingBlockInherited,
|
|
})
|
|
|
|
return { ...result, success: true }
|
|
} catch (error) {
|
|
logger.error('Failed to transfer organization ownership', {
|
|
organizationId,
|
|
currentOwnerUserId,
|
|
newOwnerUserId,
|
|
error,
|
|
})
|
|
|
|
return {
|
|
...result,
|
|
success: false,
|
|
error: getErrorMessage(error, 'Failed to transfer ownership'),
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function isSoleOwnerOfPaidOrganization(userId: string): Promise<{
|
|
isBlocker: boolean
|
|
organizationId?: string
|
|
organizationName?: string
|
|
plan?: string | null
|
|
}> {
|
|
const [ownerMembership] = await db
|
|
.select({ organizationId: member.organizationId })
|
|
.from(member)
|
|
.where(and(eq(member.userId, userId), eq(member.role, 'owner')))
|
|
.limit(1)
|
|
|
|
if (!ownerMembership) {
|
|
return { isBlocker: false }
|
|
}
|
|
|
|
const [orgSub] = await db
|
|
.select({ plan: subscriptionTable.plan })
|
|
.from(subscriptionTable)
|
|
.where(
|
|
and(
|
|
eq(subscriptionTable.referenceId, ownerMembership.organizationId),
|
|
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (!orgSub || !isPaid(orgSub.plan)) {
|
|
return { isBlocker: false }
|
|
}
|
|
|
|
const [orgRow] = await db
|
|
.select({ name: organization.name })
|
|
.from(organization)
|
|
.where(eq(organization.id, ownerMembership.organizationId))
|
|
.limit(1)
|
|
|
|
return {
|
|
isBlocker: true,
|
|
organizationId: ownerMembership.organizationId,
|
|
organizationName: orgRow?.name,
|
|
plan: orgSub.plan,
|
|
}
|
|
}
|
|
|
|
export async function isUserMemberOfOrganization(
|
|
userId: string,
|
|
organizationId: string
|
|
): Promise<{ isMember: boolean; role?: string; memberId?: string }> {
|
|
const [memberRecord] = await db
|
|
.select({ id: member.id, role: member.role })
|
|
.from(member)
|
|
.where(and(eq(member.userId, userId), eq(member.organizationId, organizationId)))
|
|
.limit(1)
|
|
|
|
if (memberRecord) {
|
|
return { isMember: true, role: memberRecord.role, memberId: memberRecord.id }
|
|
}
|
|
|
|
return { isMember: false }
|
|
}
|
|
|
|
/**
|
|
* Get user's current organization membership (if any).
|
|
*/
|
|
export async function getUserOrganization(
|
|
userId: string
|
|
): Promise<{ organizationId: string; role: string; memberId: string } | null> {
|
|
const [memberRecord] = await db
|
|
.select({
|
|
organizationId: member.organizationId,
|
|
role: member.role,
|
|
memberId: member.id,
|
|
})
|
|
.from(member)
|
|
.where(eq(member.userId, userId))
|
|
.limit(1)
|
|
|
|
return memberRecord || null
|
|
}
|