import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { member, organization, subscription as subscriptionTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' import { and, eq, inArray, or } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { createOrganizationBodySchema } from '@/lib/api/contracts/organization' import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { createOrganizationForTeamPlan, ensureOrganizationForTeamSubscription, } from '@/lib/billing/organization' import { OrganizationSlugInvalidError, OrganizationSlugTakenError, } from '@/lib/billing/organizations/create-organization' import { isOrgPlan } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { attachOwnedWorkspacesToOrganization, WorkspaceOrganizationMembershipConflictError, } from '@/lib/workspaces/organization-workspaces' const logger = createLogger('OrganizationsAPI') export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const parsed = await parseRequest(listCreatorOrganizationsContract, request, {}) if (!parsed.success) return parsed.response const userOrganizations = await db .select({ id: organization.id, name: organization.name, role: member.role, }) .from(member) .innerJoin(organization, eq(member.organizationId, organization.id)) .where( and( eq(member.userId, session.user.id), or(eq(member.role, 'owner'), eq(member.role, 'admin')) ) ) const anyMembership = await db .select({ id: member.id }) .from(member) .where(eq(member.userId, session.user.id)) .limit(1) return NextResponse.json({ organizations: userOrganizations, isMemberOfAnyOrg: anyMembership.length > 0, }) } catch (error) { logger.error('Failed to fetch organizations', { error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }) export const POST = withRouteHandler(async (request: Request) => { try { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized - no active session' }, { status: 401 }) } const user = session.user let organizationName = user.name let organizationSlug: string | undefined const rawBody = await request.json().catch(() => ({})) const parsedBody = createOrganizationBodySchema.safeParse(rawBody) if (!parsedBody.success) { return NextResponse.json( { error: getValidationErrorMessage(parsedBody.error, 'Invalid request body') }, { status: 400 } ) } if (parsedBody.data.name) { organizationName = parsedBody.data.name } if (parsedBody.data.slug) { organizationSlug = parsedBody.data.slug } const existingOrgMembership = await db .select({ organizationId: member.organizationId, role: member.role, }) .from(member) .where(eq(member.userId, user.id)) .limit(1) const existingAdminMembership = existingOrgMembership.length > 0 && isOrgAdminRole(existingOrgMembership[0].role) ? existingOrgMembership[0] : null if (existingOrgMembership.length > 0 && !existingAdminMembership) { return NextResponse.json( { error: 'You are already a member of an organization. Leave your current organization before creating a new one.', }, { status: 409 } ) } const subscriptionReferenceIds = existingAdminMembership ? [user.id, existingAdminMembership.organizationId] : [user.id] const activeOrgSubscriptions = await db .select({ id: subscriptionTable.id, plan: subscriptionTable.plan, referenceId: subscriptionTable.referenceId, status: subscriptionTable.status, seats: subscriptionTable.seats, }) .from(subscriptionTable) .where( and( inArray(subscriptionTable.referenceId, subscriptionReferenceIds), inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) ) ) const activeOrgSubscription = (existingAdminMembership ? activeOrgSubscriptions.find( (subscription) => isOrgPlan(subscription.plan) && subscription.referenceId === existingAdminMembership.organizationId ) : undefined) ?? activeOrgSubscriptions.find( (subscription) => isOrgPlan(subscription.plan) && subscription.referenceId === user.id ) ?? activeOrgSubscriptions.find((subscription) => isOrgPlan(subscription.plan)) if (!activeOrgSubscription) { return NextResponse.json( { error: 'Organization creation requires an active Team or Enterprise subscription.' }, { status: 403 } ) } logger.info('Creating organization for team plan', { userId: user.id, userName: user.name, userEmail: user.email, organizationName, organizationSlug, existingOrganizationId: existingAdminMembership?.organizationId ?? null, subscriptionReferenceId: activeOrgSubscription.referenceId, }) let organizationId: string let createdOrganization = false if (existingAdminMembership) { organizationId = existingAdminMembership.organizationId if (activeOrgSubscription.referenceId === organizationId) { await attachOwnedWorkspacesToOrganization({ ownerUserId: user.id, organizationId, }) } else { const resolvedSubscription = await ensureOrganizationForTeamSubscription(activeOrgSubscription) if (resolvedSubscription.referenceId !== organizationId) { logger.error('Recovered organization did not match existing owner/admin membership', { userId: user.id, expectedOrganizationId: organizationId, resolvedReferenceId: resolvedSubscription.referenceId, subscriptionId: activeOrgSubscription.id, }) throw new Error('Organization recovery resolved to an unexpected subscription owner') } } } else { createdOrganization = true organizationId = await createOrganizationForTeamPlan( user.id, organizationName || undefined, user.email, organizationSlug ) const resolvedSubscription = await ensureOrganizationForTeamSubscription(activeOrgSubscription) if (resolvedSubscription.referenceId !== organizationId) { logger.error('Newly created organization was not attached to the active subscription', { userId: user.id, expectedOrganizationId: organizationId, resolvedReferenceId: resolvedSubscription.referenceId, subscriptionId: activeOrgSubscription.id, }) throw new Error('Failed to link the new organization to the active subscription') } } try { await setActiveOrganizationForCurrentSession(organizationId) } catch (error) { logger.error('Failed to activate organization after creation', { organizationId, userId: user.id, error: getErrorMessage(error), }) } logger.info('Successfully ensured organization for team plan', { userId: user.id, organizationId, createdOrganization, }) if (createdOrganization) { recordAudit({ workspaceId: null, actorId: user.id, action: AuditAction.ORGANIZATION_CREATED, resourceType: AuditResourceType.ORGANIZATION, resourceId: organizationId, actorName: user.name ?? undefined, actorEmail: user.email ?? undefined, resourceName: organizationName ?? undefined, description: `Created organization "${organizationName}"`, metadata: { organizationSlug }, request, }) captureServerEvent( user.id, 'organization_created', { organization_id: organizationId, ...(organizationName ? { name: organizationName } : {}), }, { groups: { organization: organizationId } } ) } return NextResponse.json({ success: true, organizationId, created: createdOrganization, }) } catch (error) { if (error instanceof OrganizationSlugInvalidError) { return NextResponse.json( { error: 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.', }, { status: 400 } ) } if (error instanceof OrganizationSlugTakenError) { return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 }) } if (error instanceof WorkspaceOrganizationMembershipConflictError) { return NextResponse.json( { error: 'One or more members of your existing shared workspaces already belong to another organization. Remove them from those workspaces before converting them to organization-owned workspaces.', }, { status: 409 } ) } logger.error('Failed to create organization for team plan', { error: getErrorMessage(error, 'Unknown error'), stack: error instanceof Error ? error.stack : undefined, }) return NextResponse.json( { error: 'Failed to create organization', message: getErrorMessage(error, 'Unknown error'), }, { status: 500 } ) } })