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 { 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 { 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 } }