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
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { invitation, member, organization, subscription, user } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { normalizeEmail } from '@sim/utils/string'
|
|
import { and, count, eq, gt, ne } from 'drizzle-orm'
|
|
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
|
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
|
|
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
|
|
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
|
|
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
|
import { hasInflightOutboxEvent } from '@/lib/core/outbox/service'
|
|
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
|
|
|
const logger = createLogger('SeatManagement')
|
|
|
|
interface SeatValidationResult {
|
|
canInvite: boolean
|
|
reason?: string
|
|
currentSeats: number
|
|
maxSeats: number
|
|
availableSeats: number
|
|
}
|
|
|
|
interface OrganizationSeatInfo {
|
|
organizationId: string
|
|
organizationName: string
|
|
currentSeats: number
|
|
maxSeats: number
|
|
availableSeats: number
|
|
subscriptionPlan: string
|
|
canAddSeats: boolean
|
|
}
|
|
|
|
interface ValidateSeatOptions {
|
|
excludePendingInvitationId?: string
|
|
}
|
|
|
|
export async function validateSeatAvailability(
|
|
organizationId: string,
|
|
additionalSeats = 1,
|
|
options: ValidateSeatOptions = {}
|
|
): Promise<SeatValidationResult> {
|
|
try {
|
|
if (!isBillingEnabled) {
|
|
const memberCount = await db
|
|
.select({ count: count() })
|
|
.from(member)
|
|
.where(eq(member.organizationId, organizationId))
|
|
const currentSeats = memberCount[0]?.count || 0
|
|
return {
|
|
canInvite: true,
|
|
currentSeats,
|
|
maxSeats: Number.MAX_SAFE_INTEGER,
|
|
availableSeats: Number.MAX_SAFE_INTEGER,
|
|
}
|
|
}
|
|
|
|
const subscription = await getOrganizationSubscription(organizationId)
|
|
|
|
if (!subscription) {
|
|
return {
|
|
canInvite: false,
|
|
reason: 'No active subscription found',
|
|
currentSeats: 0,
|
|
maxSeats: 0,
|
|
availableSeats: 0,
|
|
}
|
|
}
|
|
|
|
if (isFree(subscription.plan)) {
|
|
return {
|
|
canInvite: false,
|
|
reason: 'Organization features require a paid plan',
|
|
currentSeats: 0,
|
|
maxSeats: 0,
|
|
availableSeats: 0,
|
|
}
|
|
}
|
|
|
|
const [memberCount] = await db
|
|
.select({ count: count() })
|
|
.from(member)
|
|
.where(eq(member.organizationId, organizationId))
|
|
|
|
const pendingFilters = [
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
ne(invitation.membershipIntent, 'external'),
|
|
gt(invitation.expiresAt, new Date()),
|
|
]
|
|
if (options.excludePendingInvitationId) {
|
|
pendingFilters.push(ne(invitation.id, options.excludePendingInvitationId))
|
|
}
|
|
const [pendingCount] = await db
|
|
.select({ count: count() })
|
|
.from(invitation)
|
|
.where(and(...pendingFilters))
|
|
|
|
const currentSeats = (memberCount?.count ?? 0) + (pendingCount?.count ?? 0)
|
|
|
|
const maxSeats = getEffectiveSeats(subscription)
|
|
const availableSeats = Math.max(0, maxSeats - currentSeats)
|
|
const canInvite = availableSeats >= additionalSeats
|
|
|
|
const result: SeatValidationResult = {
|
|
canInvite,
|
|
currentSeats,
|
|
maxSeats,
|
|
availableSeats,
|
|
}
|
|
|
|
if (!canInvite) {
|
|
if (additionalSeats === 1) {
|
|
result.reason = `No available seats. Currently using ${currentSeats} of ${maxSeats} seats.`
|
|
} else {
|
|
result.reason = `Not enough available seats. Need ${additionalSeats} seats, but only ${availableSeats} available.`
|
|
}
|
|
}
|
|
|
|
logger.debug('Seat validation result', {
|
|
organizationId,
|
|
additionalSeats,
|
|
result,
|
|
})
|
|
|
|
return result
|
|
} catch (error) {
|
|
logger.error('Failed to validate seat availability', { organizationId, additionalSeats, error })
|
|
return {
|
|
canInvite: false,
|
|
reason: 'Failed to check seat availability',
|
|
currentSeats: 0,
|
|
maxSeats: 0,
|
|
availableSeats: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get comprehensive seat information for an organization
|
|
*/
|
|
export async function getOrganizationSeatInfo(
|
|
organizationId: string
|
|
): Promise<OrganizationSeatInfo | null> {
|
|
try {
|
|
const organizationData = await db
|
|
.select({
|
|
id: organization.id,
|
|
name: organization.name,
|
|
})
|
|
.from(organization)
|
|
.where(eq(organization.id, organizationId))
|
|
.limit(1)
|
|
|
|
if (organizationData.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const [memberCountRow] = await db
|
|
.select({ count: count() })
|
|
.from(member)
|
|
.where(eq(member.organizationId, organizationId))
|
|
|
|
const [pendingCountRow] = await db
|
|
.select({ count: count() })
|
|
.from(invitation)
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
ne(invitation.membershipIntent, 'external'),
|
|
gt(invitation.expiresAt, new Date())
|
|
)
|
|
)
|
|
|
|
const currentSeats = (memberCountRow?.count ?? 0) + (pendingCountRow?.count ?? 0)
|
|
|
|
if (!isBillingEnabled) {
|
|
return {
|
|
organizationId,
|
|
organizationName: organizationData[0].name,
|
|
currentSeats,
|
|
maxSeats: Number.MAX_SAFE_INTEGER,
|
|
availableSeats: Number.MAX_SAFE_INTEGER,
|
|
subscriptionPlan: 'billing_disabled',
|
|
canAddSeats: false,
|
|
}
|
|
}
|
|
|
|
const subscription = await getOrganizationSubscription(organizationId)
|
|
|
|
if (!subscription) {
|
|
return null
|
|
}
|
|
|
|
const maxSeats = getEffectiveSeats(subscription)
|
|
|
|
const canAddSeats = !isEnterprise(subscription.plan)
|
|
|
|
const availableSeats = Math.max(0, maxSeats - currentSeats)
|
|
|
|
return {
|
|
organizationId,
|
|
organizationName: organizationData[0].name,
|
|
currentSeats,
|
|
maxSeats,
|
|
availableSeats,
|
|
subscriptionPlan: subscription.plan,
|
|
canAddSeats,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to get organization seat info', { organizationId, error })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate and reserve seats for bulk invitations
|
|
*/
|
|
export async function validateBulkInvitations(
|
|
organizationId: string,
|
|
emailList: string[]
|
|
): Promise<{
|
|
canInviteAll: boolean
|
|
validEmails: string[]
|
|
duplicateEmails: string[]
|
|
existingMembers: string[]
|
|
seatsNeeded: number
|
|
seatsAvailable: number
|
|
validationResult: SeatValidationResult
|
|
}> {
|
|
try {
|
|
const uniqueEmails = [...new Set(emailList)]
|
|
const validEmails = uniqueEmails.filter(
|
|
(email) => quickValidateEmail(normalizeEmail(email)).isValid
|
|
)
|
|
const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index)
|
|
|
|
const existingMembers = await db
|
|
.select({ userEmail: user.email })
|
|
.from(member)
|
|
.innerJoin(user, eq(member.userId, user.id))
|
|
.where(eq(member.organizationId, organizationId))
|
|
|
|
const existingEmails = existingMembers.map((m) => m.userEmail)
|
|
const newEmails = validEmails.filter((email) => !existingEmails.includes(email))
|
|
|
|
const pendingInvitations = await db
|
|
.select({ email: invitation.email })
|
|
.from(invitation)
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
ne(invitation.membershipIntent, 'external'),
|
|
gt(invitation.expiresAt, new Date())
|
|
)
|
|
)
|
|
|
|
const pendingEmails = pendingInvitations.map((i) => i.email)
|
|
const finalEmailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
|
|
|
|
const seatsNeeded = finalEmailsToInvite.length
|
|
const validationResult = await validateSeatAvailability(organizationId, seatsNeeded)
|
|
|
|
return {
|
|
canInviteAll: validationResult.canInvite && finalEmailsToInvite.length > 0,
|
|
validEmails: finalEmailsToInvite,
|
|
duplicateEmails,
|
|
existingMembers: validEmails.filter((email) => existingEmails.includes(email)),
|
|
seatsNeeded,
|
|
seatsAvailable: validationResult.availableSeats,
|
|
validationResult,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to validate bulk invitations', {
|
|
organizationId,
|
|
emailCount: emailList.length,
|
|
error,
|
|
})
|
|
|
|
const validationResult: SeatValidationResult = {
|
|
canInvite: false,
|
|
reason: 'Validation failed',
|
|
currentSeats: 0,
|
|
maxSeats: 0,
|
|
availableSeats: 0,
|
|
}
|
|
|
|
return {
|
|
canInviteAll: false,
|
|
validEmails: [],
|
|
duplicateEmails: [],
|
|
existingMembers: [],
|
|
seatsNeeded: 0,
|
|
seatsAvailable: 0,
|
|
validationResult,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get seat usage analytics for an organization
|
|
*/
|
|
export async function getOrganizationSeatAnalytics(organizationId: string) {
|
|
try {
|
|
const seatInfo = await getOrganizationSeatInfo(organizationId)
|
|
|
|
if (!seatInfo) {
|
|
return null
|
|
}
|
|
|
|
const utilizationRate =
|
|
seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0
|
|
|
|
// Member activity analytics (active/inactive counts, memberActivity) were
|
|
// derived from userStats.lastActive, which is no longer written. Dropped
|
|
// rather than report frozen data; reintroduce with a real activity source.
|
|
return {
|
|
...seatInfo,
|
|
utilizationRate: Math.round(utilizationRate * 100) / 100,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to get organization seat analytics', { organizationId, error })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync seat count from Stripe subscription quantity. Used by webhook handlers
|
|
* to keep the local DB in sync with Stripe. No-ops while a DB→Stripe seat-sync
|
|
* is still in flight for the subscription, so a stale webhook never clobbers a
|
|
* DB seat change that hasn't been pushed yet (DB is the source of truth).
|
|
*/
|
|
export async function syncSeatsFromStripeQuantity(
|
|
subscriptionId: string,
|
|
currentSeats: number | null,
|
|
stripeQuantity: number
|
|
): Promise<{ synced: boolean; previousSeats: number | null; newSeats: number }> {
|
|
const effectiveCurrentSeats = currentSeats ?? 0
|
|
|
|
// Only update if quantity differs
|
|
if (stripeQuantity === effectiveCurrentSeats) {
|
|
return {
|
|
synced: false,
|
|
previousSeats: effectiveCurrentSeats,
|
|
newSeats: stripeQuantity,
|
|
}
|
|
}
|
|
|
|
// The DB is the source of truth for Team seats; we push the DB value to
|
|
// Stripe asynchronously via the seat-sync outbox. While that sync is still in
|
|
// flight the DB is intentionally ahead of Stripe, so skip this Stripe-to-DB
|
|
// sync — otherwise a stale customer.subscription.updated webhook could clobber
|
|
// the not-yet-pushed value (e.g. revert a just-removed member's seat).
|
|
const seatSyncInFlight = await hasInflightOutboxEvent(
|
|
OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
|
|
'subscriptionId',
|
|
subscriptionId
|
|
)
|
|
if (seatSyncInFlight) {
|
|
logger.info('Skipping Stripe seat sync; a seat-sync to Stripe is still in flight', {
|
|
subscriptionId,
|
|
stripeQuantity,
|
|
currentSeats: effectiveCurrentSeats,
|
|
})
|
|
return {
|
|
synced: false,
|
|
previousSeats: effectiveCurrentSeats,
|
|
newSeats: effectiveCurrentSeats,
|
|
}
|
|
}
|
|
|
|
try {
|
|
await db
|
|
.update(subscription)
|
|
.set({ seats: stripeQuantity })
|
|
.where(eq(subscription.id, subscriptionId))
|
|
|
|
logger.info('Synced seat count from Stripe', {
|
|
subscriptionId,
|
|
previousSeats: effectiveCurrentSeats,
|
|
newSeats: stripeQuantity,
|
|
})
|
|
|
|
return {
|
|
synced: true,
|
|
previousSeats: effectiveCurrentSeats,
|
|
newSeats: stripeQuantity,
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to sync seat count from Stripe', {
|
|
subscriptionId,
|
|
stripeQuantity,
|
|
error,
|
|
})
|
|
throw error
|
|
}
|
|
}
|