chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { dataDrains } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
deleteDataDrainContract,
|
||||
getDataDrainContract,
|
||||
updateDataDrainContract,
|
||||
} from '@/lib/api/contracts/data-drains'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
|
||||
import { getDestination } from '@/lib/data-drains/destinations/registry'
|
||||
import { encryptCredentials } from '@/lib/data-drains/encryption'
|
||||
import { serializeDrain } from '@/lib/data-drains/serializers'
|
||||
|
||||
const logger = createLogger('DataDrainAPI')
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; drainId: string }> }
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId, drainId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: false })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(getDataDrainContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const drain = await loadDrain(organizationId, drainId)
|
||||
if (!drain) {
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ drain: serializeDrain(drain) })
|
||||
})
|
||||
|
||||
export const PUT = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId, drainId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(updateDataDrainContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const body = parsed.data.body
|
||||
|
||||
const drain = await loadDrain(organizationId, drainId)
|
||||
if (!drain) {
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (body.name !== undefined && body.name !== drain.name) {
|
||||
const [conflict] = await db
|
||||
.select({ id: dataDrains.id })
|
||||
.from(dataDrains)
|
||||
.where(
|
||||
and(
|
||||
eq(dataDrains.organizationId, organizationId),
|
||||
eq(dataDrains.name, body.name),
|
||||
ne(dataDrains.id, drainId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (conflict) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A data drain with this name already exists in this organization' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (body.source !== undefined && body.source !== drain.source) {
|
||||
return NextResponse.json({ error: 'source cannot be changed after creation' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updates: Partial<typeof dataDrains.$inferInsert> = { updatedAt: new Date() }
|
||||
if (body.name !== undefined) updates.name = body.name
|
||||
if (body.scheduleCadence !== undefined) updates.scheduleCadence = body.scheduleCadence
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled
|
||||
|
||||
if (body.destinationType !== undefined && body.destinationType !== drain.destinationType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'destinationType cannot be changed after creation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (body.destinationConfig !== undefined || body.destinationCredentials !== undefined) {
|
||||
const destination = getDestination(drain.destinationType)
|
||||
if (body.destinationConfig !== undefined) {
|
||||
const configResult = destination.configSchema.safeParse(body.destinationConfig)
|
||||
if (!configResult.success) return validationErrorResponse(configResult.error)
|
||||
updates.destinationConfig = configResult.data as Record<string, unknown>
|
||||
}
|
||||
if (body.destinationCredentials !== undefined) {
|
||||
const credentialsResult = destination.credentialsSchema.safeParse(body.destinationCredentials)
|
||||
if (!credentialsResult.success) return validationErrorResponse(credentialsResult.error)
|
||||
updates.destinationCredentials = await encryptCredentials(credentialsResult.data)
|
||||
}
|
||||
}
|
||||
|
||||
let updated: typeof dataDrains.$inferSelect | undefined
|
||||
try {
|
||||
;[updated] = await db
|
||||
.update(dataDrains)
|
||||
.set(updates)
|
||||
.where(eq(dataDrains.id, drainId))
|
||||
.returning()
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A data drain with this name already exists in this organization' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
// Concurrent DELETE landed between loadDrain() and this UPDATE.
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Data drain updated', { drainId, organizationId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: access.session.user.id,
|
||||
action: AuditAction.DATA_DRAIN_UPDATED,
|
||||
resourceType: AuditResourceType.DATA_DRAIN,
|
||||
resourceId: drainId,
|
||||
actorName: access.session.user.name ?? undefined,
|
||||
actorEmail: access.session.user.email ?? undefined,
|
||||
resourceName: updated.name,
|
||||
description: `Updated data drain '${updated.name}'`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
changes: {
|
||||
name: body.name,
|
||||
source: body.source,
|
||||
scheduleCadence: body.scheduleCadence,
|
||||
enabled: body.enabled,
|
||||
destinationConfigChanged: body.destinationConfig !== undefined,
|
||||
destinationCredentialsChanged: body.destinationCredentials !== undefined,
|
||||
},
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ drain: serializeDrain(updated) })
|
||||
})
|
||||
|
||||
export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId, drainId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(deleteDataDrainContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const drain = await loadDrain(organizationId, drainId)
|
||||
if (!drain) {
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.delete(dataDrains).where(eq(dataDrains.id, drainId))
|
||||
|
||||
logger.info('Data drain deleted', { drainId, organizationId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: access.session.user.id,
|
||||
action: AuditAction.DATA_DRAIN_DELETED,
|
||||
resourceType: AuditResourceType.DATA_DRAIN,
|
||||
resourceId: drainId,
|
||||
actorName: access.session.user.name ?? undefined,
|
||||
actorEmail: access.session.user.email ?? undefined,
|
||||
resourceName: drain.name,
|
||||
description: `Deleted data drain '${drain.name}'`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
source: drain.source,
|
||||
destinationType: drain.destinationType,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true as const })
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { dataDrainRuns } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { runDataDrainContract } from '@/lib/api/contracts/data-drains'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getJobQueue } from '@/lib/core/async-jobs'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
|
||||
|
||||
const logger = createLogger('DataDrainRunAPI')
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; drainId: string }> }
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId, drainId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(runDataDrainContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const drain = await loadDrain(organizationId, drainId)
|
||||
if (!drain) {
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
if (!drain.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot run a disabled drain. Enable it first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Reject obvious double-fires up-front. The job-queue concurrencyKey is the
|
||||
// real serialization barrier (it covers the gap between enqueue and the
|
||||
// runner inserting the `running` row), but this gives the user immediate
|
||||
// feedback when a run is already in flight.
|
||||
const [inFlight] = await db
|
||||
.select({ id: dataDrainRuns.id })
|
||||
.from(dataDrainRuns)
|
||||
.where(and(eq(dataDrainRuns.drainId, drainId), eq(dataDrainRuns.status, 'running')))
|
||||
.limit(1)
|
||||
if (inFlight) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A run is already in progress for this drain' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const queue = await getJobQueue()
|
||||
const jobId = await queue.enqueue(
|
||||
'run-data-drain',
|
||||
{ drainId, trigger: 'manual' },
|
||||
{ concurrencyKey: `data-drain:${drainId}` }
|
||||
)
|
||||
|
||||
logger.info('Manually enqueued data drain run', { drainId, organizationId, jobId })
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: access.session.user.id,
|
||||
action: AuditAction.DATA_DRAIN_RAN,
|
||||
resourceType: AuditResourceType.DATA_DRAIN,
|
||||
resourceId: drainId,
|
||||
actorName: access.session.user.name ?? undefined,
|
||||
actorEmail: access.session.user.email ?? undefined,
|
||||
resourceName: drain.name,
|
||||
description: `Triggered manual run for data drain '${drain.name}'`,
|
||||
metadata: { organizationId, jobId, trigger: 'manual' },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ jobId })
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { db } from '@sim/db'
|
||||
import { dataDrainRuns } from '@sim/db/schema'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { listDataDrainRunsContract } from '@/lib/api/contracts/data-drains'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
|
||||
import { serializeDrainRun } from '@/lib/data-drains/serializers'
|
||||
|
||||
const DEFAULT_LIMIT = 25
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; drainId: string }> }
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId, drainId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: false })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(listDataDrainRunsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const drain = await loadDrain(organizationId, drainId)
|
||||
if (!drain) {
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const limit = parsed.data.query?.limit ?? DEFAULT_LIMIT
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(dataDrainRuns)
|
||||
.where(eq(dataDrainRuns.drainId, drainId))
|
||||
.orderBy(desc(dataDrainRuns.startedAt))
|
||||
.limit(limit)
|
||||
|
||||
return NextResponse.json({ runs: runs.map(serializeDrainRun) })
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { testDataDrainContract } from '@/lib/api/contracts/data-drains'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authorizeDrainAccess, loadDrain } from '@/lib/data-drains/access'
|
||||
import { getDestination } from '@/lib/data-drains/destinations/registry'
|
||||
import { decryptCredentials } from '@/lib/data-drains/encryption'
|
||||
|
||||
const logger = createLogger('DataDrainTestAPI')
|
||||
|
||||
const TEST_TIMEOUT_MS = 10_000
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string; drainId: string }> }
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId, drainId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(testDataDrainContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const drain = await loadDrain(organizationId, drainId)
|
||||
if (!drain) {
|
||||
return NextResponse.json({ error: 'Data drain not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const destination = getDestination(drain.destinationType)
|
||||
if (!destination.test) {
|
||||
return NextResponse.json(
|
||||
{ error: `Destination '${drain.destinationType}' does not support connection testing` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const config = destination.configSchema.parse(drain.destinationConfig)
|
||||
const credentials = destination.credentialsSchema.parse(
|
||||
await decryptCredentials(drain.destinationCredentials)
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS)
|
||||
try {
|
||||
await destination.test({ config, credentials, signal: controller.signal })
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: access.session.user.id,
|
||||
action: AuditAction.DATA_DRAIN_TESTED,
|
||||
resourceType: AuditResourceType.DATA_DRAIN,
|
||||
resourceId: drainId,
|
||||
actorName: access.session.user.name ?? undefined,
|
||||
actorEmail: access.session.user.email ?? undefined,
|
||||
resourceName: drain.name,
|
||||
description: `Tested connection for data drain '${drain.name}' (success)`,
|
||||
metadata: { organizationId, destinationType: drain.destinationType, outcome: 'success' },
|
||||
request,
|
||||
})
|
||||
return NextResponse.json({ ok: true as const })
|
||||
} catch (error) {
|
||||
const message = toError(error).message
|
||||
logger.warn('Data drain test connection failed', {
|
||||
drainId,
|
||||
destinationType: drain.destinationType,
|
||||
error: message,
|
||||
})
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: access.session.user.id,
|
||||
action: AuditAction.DATA_DRAIN_TESTED,
|
||||
resourceType: AuditResourceType.DATA_DRAIN,
|
||||
resourceId: drainId,
|
||||
actorName: access.session.user.name ?? undefined,
|
||||
actorEmail: access.session.user.email ?? undefined,
|
||||
resourceName: drain.name,
|
||||
description: `Tested connection for data drain '${drain.name}' (failed)`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
destinationType: drain.destinationType,
|
||||
outcome: 'failed',
|
||||
error: message,
|
||||
},
|
||||
request,
|
||||
})
|
||||
return NextResponse.json({ error: message }, { status: 400 })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { dataDrains } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createDataDrainContract, listDataDrainsContract } from '@/lib/api/contracts/data-drains'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { authorizeDrainAccess } from '@/lib/data-drains/access'
|
||||
import { getDestination } from '@/lib/data-drains/destinations/registry'
|
||||
import { encryptCredentials } from '@/lib/data-drains/encryption'
|
||||
import { serializeDrain } from '@/lib/data-drains/serializers'
|
||||
|
||||
const logger = createLogger('DataDrainsAPI')
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> }
|
||||
|
||||
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: false })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(listDataDrainsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(dataDrains)
|
||||
.where(eq(dataDrains.organizationId, organizationId))
|
||||
.orderBy(asc(dataDrains.createdAt))
|
||||
|
||||
return NextResponse.json({ drains: rows.map(serializeDrain) })
|
||||
})
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
const { id: organizationId } = await context.params
|
||||
const access = await authorizeDrainAccess(organizationId, { requireMutating: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const parsed = await parseRequest(createDataDrainContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const body = parsed.data.body
|
||||
|
||||
if (!body.destinationCredentials) {
|
||||
return NextResponse.json(
|
||||
{ error: 'destinationCredentials is required when creating a drain' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const destination = getDestination(body.destinationType)
|
||||
const configResult = destination.configSchema.safeParse(body.destinationConfig)
|
||||
if (!configResult.success) return validationErrorResponse(configResult.error)
|
||||
const credentialsResult = destination.credentialsSchema.safeParse(body.destinationCredentials)
|
||||
if (!credentialsResult.success) return validationErrorResponse(credentialsResult.error)
|
||||
const encryptedCredentials = await encryptCredentials(credentialsResult.data)
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: dataDrains.id })
|
||||
.from(dataDrains)
|
||||
.where(and(eq(dataDrains.organizationId, organizationId), eq(dataDrains.name, body.name)))
|
||||
.limit(1)
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A data drain with this name already exists in this organization' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const id = generateId()
|
||||
const now = new Date()
|
||||
let inserted: typeof dataDrains.$inferSelect | undefined
|
||||
try {
|
||||
;[inserted] = await db
|
||||
.insert(dataDrains)
|
||||
.values({
|
||||
id,
|
||||
organizationId,
|
||||
name: body.name,
|
||||
source: body.source,
|
||||
destinationType: body.destinationType,
|
||||
destinationConfig: configResult.data as Record<string, unknown>,
|
||||
destinationCredentials: encryptedCredentials,
|
||||
scheduleCadence: body.scheduleCadence,
|
||||
enabled: body.enabled ?? true,
|
||||
cursor: null,
|
||||
createdBy: access.session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A data drain with this name already exists in this organization' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
throw new Error('Insert returned no row')
|
||||
}
|
||||
|
||||
logger.info('Data drain created', {
|
||||
drainId: id,
|
||||
organizationId,
|
||||
source: body.source,
|
||||
destinationType: body.destinationType,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: access.session.user.id,
|
||||
action: AuditAction.DATA_DRAIN_CREATED,
|
||||
resourceType: AuditResourceType.DATA_DRAIN,
|
||||
resourceId: id,
|
||||
actorName: access.session.user.name ?? undefined,
|
||||
actorEmail: access.session.user.email ?? undefined,
|
||||
resourceName: body.name,
|
||||
description: `Created data drain '${body.name}'`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
source: body.source,
|
||||
destinationType: body.destinationType,
|
||||
scheduleCadence: body.scheduleCadence,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ drain: serializeDrain(inserted) }, { status: 201 })
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import type { DataRetentionSettings } from '@sim/db/schema'
|
||||
import { member, organization, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
type OrganizationRetentionValues,
|
||||
updateOrganizationDataRetentionContract,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import { isBillingEnabled } from '@/lib/core/config/env-flags'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { coercePiiLanguage } from '@/lib/guardrails/pii-entities'
|
||||
|
||||
const logger = createLogger('DataRetentionAPI')
|
||||
|
||||
function enterpriseDefaults(): OrganizationRetentionValues {
|
||||
return {
|
||||
logRetentionHours: CLEANUP_CONFIG['cleanup-logs'].defaults.enterprise,
|
||||
softDeleteRetentionHours: CLEANUP_CONFIG['cleanup-soft-deletes'].defaults.enterprise,
|
||||
taskCleanupHours: CLEANUP_CONFIG['cleanup-tasks'].defaults.enterprise,
|
||||
piiRedaction: null,
|
||||
retentionOverrides: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfigured(
|
||||
settings: DataRetentionSettings | null | undefined
|
||||
): OrganizationRetentionValues {
|
||||
return {
|
||||
logRetentionHours: settings?.logRetentionHours ?? null,
|
||||
softDeleteRetentionHours: settings?.softDeleteRetentionHours ?? null,
|
||||
taskCleanupHours: settings?.taskCleanupHours ?? null,
|
||||
piiRedaction: settings?.piiRedaction?.rules
|
||||
? {
|
||||
rules: settings.piiRedaction.rules.map((rule) => ({
|
||||
...rule,
|
||||
language: coercePiiLanguage(rule.language),
|
||||
stages: rule.stages
|
||||
? {
|
||||
input: {
|
||||
...rule.stages.input,
|
||||
language: coercePiiLanguage(rule.stages.input?.language),
|
||||
},
|
||||
blockOutputs: {
|
||||
...rule.stages.blockOutputs,
|
||||
language: coercePiiLanguage(rule.stages.blockOutputs?.language),
|
||||
},
|
||||
logs: {
|
||||
...rule.stages.logs,
|
||||
language: coercePiiLanguage(rule.stages.logs?.language),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
retentionOverrides: settings?.retentionOverrides ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which granular stages (`input`/`blockOutputs`) are already enabled per rule
|
||||
* target (`workspaceId ?? ''` = the org default). Used to gate the
|
||||
* `pii-granular-redaction` flag on *new* enablement only: when the flag is off,
|
||||
* an org that already configured granular stages must still be able to re-save
|
||||
* unrelated settings (the UI re-sends the full PII snapshot every save), so we
|
||||
* reject only a stage transitioning off→on, never a preserved one.
|
||||
*/
|
||||
function granularStageEnablement(
|
||||
settings: OrganizationRetentionValues['piiRedaction']
|
||||
): Map<string, { input: boolean; blockOutputs: boolean }> {
|
||||
const map = new Map<string, { input: boolean; blockOutputs: boolean }>()
|
||||
for (const rule of settings?.rules ?? []) {
|
||||
map.set(rule.workspaceId ?? '', {
|
||||
input: rule.stages?.input?.enabled === true,
|
||||
blockOutputs: rule.stages?.blockOutputs?.enabled === true,
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/data-retention
|
||||
* Returns the organization's data retention settings.
|
||||
* Accessible by any member of the organization.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({ dataRetentionSettings: organization.dataRetentionSettings })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const isEnterprise = !isBillingEnabled || (await isOrganizationOnEnterprisePlan(organizationId))
|
||||
const [piiRedactionEnabled, piiGranularRedactionEnabled] = await Promise.all([
|
||||
isFeatureEnabled('pii-redaction'),
|
||||
isFeatureEnabled('pii-granular-redaction'),
|
||||
])
|
||||
const configured = normalizeConfigured(org.dataRetentionSettings)
|
||||
const defaults = enterpriseDefaults()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
isEnterprise,
|
||||
defaults,
|
||||
configured,
|
||||
effective: isEnterprise ? configured : defaults,
|
||||
piiRedactionEnabled,
|
||||
piiGranularRedactionEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]/data-retention
|
||||
* Updates the organization's data retention settings.
|
||||
* Requires enterprise plan and owner/admin role.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateOrganizationDataRetentionContract, request, context, {
|
||||
validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
const body = parsed.data.body
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Only organization owners and admins can update data retention' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (isBillingEnabled) {
|
||||
const hasEnterprise = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!hasEnterprise) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Data Retention is available on Enterprise plans only' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [currentOrg] = await db
|
||||
.select({
|
||||
name: organization.name,
|
||||
dataRetentionSettings: organization.dataRetentionSettings,
|
||||
})
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!currentOrg) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [piiRedactionEnabled, piiGranularRedactionEnabled] = await Promise.all([
|
||||
isFeatureEnabled('pii-redaction'),
|
||||
isFeatureEnabled('pii-granular-redaction'),
|
||||
])
|
||||
|
||||
const current = normalizeConfigured(currentOrg.dataRetentionSettings)
|
||||
const merged: DataRetentionSettings = { ...current }
|
||||
if (body.logRetentionHours !== undefined) {
|
||||
merged.logRetentionHours = body.logRetentionHours
|
||||
}
|
||||
if (body.softDeleteRetentionHours !== undefined) {
|
||||
merged.softDeleteRetentionHours = body.softDeleteRetentionHours
|
||||
}
|
||||
if (body.taskCleanupHours !== undefined) {
|
||||
merged.taskCleanupHours = body.taskCleanupHours
|
||||
}
|
||||
if (body.piiRedaction !== undefined) {
|
||||
if (!piiRedactionEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'PII redaction is not enabled for this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
if (!piiGranularRedactionEnabled) {
|
||||
// Reject only a granular stage transitioning off→on; a body that merely
|
||||
// preserves already-enabled granular stages must still save (the UI
|
||||
// re-sends the full snapshot on every save), so existing orgs aren't
|
||||
// locked out of unrelated retention changes when the flag is off.
|
||||
const currentGranular = granularStageEnablement(current.piiRedaction)
|
||||
const newlyEnablesGranular = (body.piiRedaction?.rules ?? []).some((rule) => {
|
||||
const cur = currentGranular.get(rule.workspaceId ?? '')
|
||||
return (
|
||||
(rule.stages?.input?.enabled === true && !cur?.input) ||
|
||||
(rule.stages?.blockOutputs?.enabled === true && !cur?.blockOutputs)
|
||||
)
|
||||
})
|
||||
if (newlyEnablesGranular) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Granular PII redaction (workflow input and block outputs) is not enabled for this organization',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
merged.piiRedaction = body.piiRedaction
|
||||
}
|
||||
if (body.retentionOverrides !== undefined) {
|
||||
merged.retentionOverrides = body.retentionOverrides
|
||||
}
|
||||
|
||||
const targetedWorkspaceIds = new Set<string>()
|
||||
for (const override of body.retentionOverrides ?? []) {
|
||||
targetedWorkspaceIds.add(override.workspaceId)
|
||||
}
|
||||
for (const rule of body.piiRedaction?.rules ?? []) {
|
||||
if (rule.workspaceId) targetedWorkspaceIds.add(rule.workspaceId)
|
||||
}
|
||||
if (targetedWorkspaceIds.size > 0) {
|
||||
const ids = [...targetedWorkspaceIds]
|
||||
const orgWorkspaces = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.organizationId, organizationId), inArray(workspace.id, ids)))
|
||||
const known = new Set(orgWorkspaces.map((row) => row.id))
|
||||
const unknown = ids.filter((id) => !known.has(id))
|
||||
if (unknown.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Override targets workspaces outside this organization: ${unknown.join(', ')}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(organization)
|
||||
.set({ dataRetentionSettings: merged, updatedAt: new Date() })
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning({ dataRetentionSettings: organization.dataRetentionSettings })
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORGANIZATION_UPDATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: currentOrg.name,
|
||||
description: 'Updated data retention settings',
|
||||
metadata: { changes: body },
|
||||
request,
|
||||
})
|
||||
|
||||
const configured = normalizeConfigured(updated.dataRetentionSettings)
|
||||
const defaults = enterpriseDefaults()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
isEnterprise: true,
|
||||
defaults,
|
||||
configured,
|
||||
effective: configured,
|
||||
piiRedactionEnabled,
|
||||
piiGranularRedactionEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createMockRequest, createSession, loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockDbState,
|
||||
mockGetSession,
|
||||
mockValidateInvitationsAllowed,
|
||||
mockValidateSeatAvailability,
|
||||
mockCreatePendingInvitation,
|
||||
mockSendInvitationEmail,
|
||||
mockCancelPendingInvitation,
|
||||
mockGrantWorkspaceAccessDirectly,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDbState: {
|
||||
selectResults: [] as any[],
|
||||
},
|
||||
mockGetSession: vi.fn(),
|
||||
mockValidateInvitationsAllowed: vi.fn(),
|
||||
mockValidateSeatAvailability: vi.fn(),
|
||||
mockCreatePendingInvitation: vi.fn(),
|
||||
mockSendInvitationEmail: vi.fn(),
|
||||
mockCancelPendingInvitation: vi.fn(),
|
||||
mockGrantWorkspaceAccessDirectly: vi.fn(),
|
||||
}))
|
||||
|
||||
function createSelectChain() {
|
||||
const chain: any = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.leftJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
|
||||
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
|
||||
const rows = mockDbState.selectResults.shift() ?? []
|
||||
return Promise.resolve(callback(rows))
|
||||
})
|
||||
return chain
|
||||
}
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockImplementation(() => createSelectChain()),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
invitation: {
|
||||
id: 'invitation.id',
|
||||
organizationId: 'invitation.organizationId',
|
||||
status: 'invitation.status',
|
||||
email: 'invitation.email',
|
||||
kind: 'invitation.kind',
|
||||
role: 'invitation.role',
|
||||
inviterId: 'invitation.inviterId',
|
||||
expiresAt: 'invitation.expiresAt',
|
||||
createdAt: 'invitation.createdAt',
|
||||
},
|
||||
member: {
|
||||
organizationId: 'member.organizationId',
|
||||
userId: 'member.userId',
|
||||
role: 'member.role',
|
||||
},
|
||||
organization: {
|
||||
id: 'organization.id',
|
||||
name: 'organization.name',
|
||||
},
|
||||
user: {
|
||||
id: 'user.id',
|
||||
name: 'user.name',
|
||||
email: 'user.email',
|
||||
},
|
||||
workspace: {
|
||||
id: 'workspace.id',
|
||||
name: 'workspace.name',
|
||||
organizationId: 'workspace.organizationId',
|
||||
workspaceMode: 'workspace.workspaceMode',
|
||||
},
|
||||
permissions: {
|
||||
entityId: 'permissions.entityId',
|
||||
entityType: 'permissions.entityType',
|
||||
userId: 'permissions.userId',
|
||||
},
|
||||
invitationWorkspaceGrant: {
|
||||
invitationId: 'invitationWorkspaceGrant.invitationId',
|
||||
workspaceId: 'invitationWorkspaceGrant.workspaceId',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
|
||||
inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/validation/seat-management', () => ({
|
||||
validateBulkInvitations: vi.fn(),
|
||||
validateSeatAvailability: mockValidateSeatAvailability,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/send', () => ({
|
||||
createPendingInvitation: mockCreatePendingInvitation,
|
||||
sendInvitationEmail: mockSendInvitationEmail,
|
||||
cancelPendingInvitation: mockCancelPendingInvitation,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/direct-grant', () => ({
|
||||
grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/validation', () => ({
|
||||
quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/policy', () => ({
|
||||
isOrganizationWorkspace: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/access-control/utils/permission-check', () => ({
|
||||
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {},
|
||||
validateInvitationsAllowed: mockValidateInvitationsAllowed,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/organizations/[id]/invitations/route'
|
||||
|
||||
describe('POST /api/organizations/[id]/invitations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbState.selectResults = []
|
||||
mockValidateInvitationsAllowed.mockResolvedValue(undefined)
|
||||
mockValidateSeatAvailability.mockResolvedValue({
|
||||
canInvite: true,
|
||||
currentSeats: 1,
|
||||
maxSeats: 5,
|
||||
availableSeats: 4,
|
||||
})
|
||||
mockCreatePendingInvitation.mockResolvedValue({
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
})
|
||||
mockSendInvitationEmail.mockResolvedValue({ success: true })
|
||||
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
|
||||
})
|
||||
|
||||
it('creates a unified invitation and sends a single email', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ emails: ['invitee@example.com'] },
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'organization',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
role: 'member',
|
||||
grants: [],
|
||||
})
|
||||
)
|
||||
expect(mockSendInvitationEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'organization', email: 'invitee@example.com' })
|
||||
)
|
||||
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[{ userId: 'user-2', workspaceId: 'ws-1' }],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['member@example.com'],
|
||||
workspaceInvitations: [
|
||||
{ workspaceId: 'ws-1', permission: 'write' },
|
||||
{ workspaceId: 'ws-2', permission: 'write' },
|
||||
],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
email: 'member@example.com',
|
||||
workspaceId: 'ws-2',
|
||||
permission: 'write',
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data.invitationsSent).toBe(0)
|
||||
expect(body.data.directlyAdded).toEqual(['member@example.com'])
|
||||
expect(body.data.directlyAddedCount).toBe(1)
|
||||
expect(body.data.existingMembers).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a partially-failed member only as added, never in both buckets', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
// First grant succeeds, second throws (e.g. transient DB error).
|
||||
mockGrantWorkspaceAccessDirectly
|
||||
.mockResolvedValueOnce({ outcome: 'added', permission: 'write' })
|
||||
.mockRejectedValueOnce(new Error('db blip'))
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['member@example.com'],
|
||||
workspaceInvitations: [
|
||||
{ workspaceId: 'ws-1', permission: 'write' },
|
||||
{ workspaceId: 'ws-2', permission: 'write' },
|
||||
],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(2)
|
||||
const body = await response.json()
|
||||
expect(body.data.directlyAdded).toEqual(['member@example.com'])
|
||||
expect(body.data.failedInvitations).toEqual([])
|
||||
})
|
||||
|
||||
it('returns 207 with both successes and failures when one member is added and another fails', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockGrantWorkspaceAccessDirectly
|
||||
.mockResolvedValueOnce({ outcome: 'added', permission: 'write' })
|
||||
.mockRejectedValueOnce(new Error('db blip'))
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[
|
||||
{ userId: 'user-a', userEmail: 'a@example.com' },
|
||||
{ userId: 'user-b', userEmail: 'b@example.com' },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['a@example.com', 'b@example.com'],
|
||||
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(207)
|
||||
const body = await response.json()
|
||||
expect(body.success).toBe(false)
|
||||
expect(body.data.directlyAdded).toEqual(['a@example.com'])
|
||||
expect(body.data.directlyAddedCount).toBe(1)
|
||||
expect(body.data.failedInvitations).toEqual([{ email: 'b@example.com', error: 'db blip' }])
|
||||
})
|
||||
|
||||
it('returns 400 when an existing member already has access to every selected workspace', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[{ userId: 'user-2', workspaceId: 'ws-1' }],
|
||||
[],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['member@example.com'],
|
||||
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('already has access')
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invites new emails to the organization and adds existing members to workspaces in one batch', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['new@example.com', 'member@example.com'],
|
||||
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'read' }],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'organization',
|
||||
email: 'new@example.com',
|
||||
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
|
||||
})
|
||||
)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
email: 'member@example.com',
|
||||
workspaceId: 'ws-1',
|
||||
permission: 'read',
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data.invitationsSent).toBe(1)
|
||||
expect(body.data.invitedEmails).toEqual(['new@example.com'])
|
||||
expect(body.data.directlyAdded).toEqual(['member@example.com'])
|
||||
expect(body.data.directlyAddedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('still rejects existing members on the non-batch organization invite path', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ emails: ['member@example.com'] },
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe(
|
||||
'Failed to send invitation. User is already a part of the organization.'
|
||||
)
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the pending invitation when email delivery fails', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'mailer unavailable' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ emails: ['invitee@example.com'] },
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,632 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
user,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
inviteOrganizationMembersContract,
|
||||
organizationParamsSchema,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
validateBulkInvitations,
|
||||
validateSeatAvailability,
|
||||
} from '@/lib/billing/validation/seat-management'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { grantWorkspaceAccessDirectly } from '@/lib/invitations/direct-grant'
|
||||
import {
|
||||
cancelPendingInvitation,
|
||||
createPendingInvitation,
|
||||
sendInvitationEmail,
|
||||
} from '@/lib/invitations/send'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { isOrganizationWorkspace } from '@/lib/workspaces/policy'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
interface WorkspaceGrantPayload {
|
||||
workspaceId: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const paramsResult = organizationParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id: organizationId } = paramsResult.data
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry.role
|
||||
if (!isOrgAdminRole(userRole)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
kind: invitation.kind,
|
||||
role: invitation.role,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
inviterName: user.name,
|
||||
inviterEmail: user.email,
|
||||
})
|
||||
.from(invitation)
|
||||
.leftJoin(user, eq(invitation.inviterId, user.id))
|
||||
.where(eq(invitation.organizationId, organizationId))
|
||||
.orderBy(invitation.createdAt)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { invitations, userRole },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization invitations', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(inviteOrganizationMembersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
await validateInvitationsAllowed(session.user.id, { organizationId })
|
||||
|
||||
const validateOnly = parsed.data.query.validate === true
|
||||
const isBatch = parsed.data.query.batch === true
|
||||
|
||||
const { email, emails, role = 'member', workspaceInvitations } = parsed.data.body
|
||||
const invitationEmails = email ? [email] : emails
|
||||
|
||||
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOrgAdminRole(memberEntry.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (validateOnly) {
|
||||
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: validationResult,
|
||||
validatedBy: session.user.id,
|
||||
validatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
const [organizationEntry] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!organizationEntry) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const processedEmails = Array.from(
|
||||
new Set(
|
||||
invitationEmails
|
||||
.map((raw) => {
|
||||
const normalized = normalizeEmail(raw)
|
||||
return quickValidateEmail(normalized).isValid ? normalized : null
|
||||
})
|
||||
.filter((email): email is string => !!email)
|
||||
)
|
||||
)
|
||||
|
||||
if (processedEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validGrants: WorkspaceGrantPayload[] = []
|
||||
const workspaceNameById = new Map<string, string>()
|
||||
if (isBatch) {
|
||||
if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one organization workspace for this invitation.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
if (validGrants.some((grant) => grant.workspaceId === wsInvitation.workspaceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
|
||||
if (!canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `You don't have permission to invite users to workspace ${wsInvitation.workspaceId}`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [workspaceEntry] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceMode: workspace.workspaceMode,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, wsInvitation.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceEntry || !isOrganizationWorkspace(workspaceEntry)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace ${wsInvitation.workspaceId} is not an organization-owned workspace.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (workspaceEntry.organizationId !== organizationId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace ${wsInvitation.workspaceId} does not belong to this organization.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await validateInvitationsAllowed(session.user.id, wsInvitation.workspaceId)
|
||||
|
||||
workspaceNameById.set(workspaceEntry.id, workspaceEntry.name)
|
||||
validGrants.push({
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
permission: wsInvitation.permission,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const existingMembers = await db
|
||||
.select({ userId: member.userId, userEmail: user.email })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
const memberUserIdByEmail = new Map(
|
||||
existingMembers.map((m) => [m.userEmail.toLowerCase(), m.userId])
|
||||
)
|
||||
const newEmails = processedEmails.filter((email) => !memberUserIdByEmail.has(email))
|
||||
const memberEmails = processedEmails.filter((email) => memberUserIdByEmail.has(email))
|
||||
|
||||
const existingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
|
||||
const pendingEmails = existingInvitations.map((i) => i.email.toLowerCase())
|
||||
const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
|
||||
|
||||
/**
|
||||
* Existing organization members are not re-invited to the organization,
|
||||
* but in batch mode they still receive a workspace invitation covering
|
||||
* the selected workspaces they don't already have access to (or a
|
||||
* pending invitation for). The inviter's own email is always treated as
|
||||
* covered.
|
||||
*/
|
||||
const memberWorkspaceInvites: Array<{ email: string; grants: WorkspaceGrantPayload[] }> = []
|
||||
const membersAlreadyCovered: string[] = []
|
||||
|
||||
if (isBatch) {
|
||||
const inviterEmail = session.user.email?.toLowerCase() ?? null
|
||||
const eligibleMemberEmails = memberEmails.filter((email) => email !== inviterEmail)
|
||||
membersAlreadyCovered.push(...memberEmails.filter((email) => email === inviterEmail))
|
||||
|
||||
const grantWorkspaceIds = validGrants.map((grant) => grant.workspaceId)
|
||||
const eligibleMemberUserIds = eligibleMemberEmails.map(
|
||||
(email) => memberUserIdByEmail.get(email) as string
|
||||
)
|
||||
|
||||
const accessibleRows =
|
||||
eligibleMemberUserIds.length > 0
|
||||
? await db
|
||||
.select({ userId: permissions.userId, workspaceId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
inArray(permissions.userId, eligibleMemberUserIds),
|
||||
inArray(permissions.entityId, grantWorkspaceIds)
|
||||
)
|
||||
)
|
||||
: []
|
||||
const accessibleByUserId = new Map<string, Set<string>>()
|
||||
for (const row of accessibleRows) {
|
||||
const workspaceIds = accessibleByUserId.get(row.userId) ?? new Set<string>()
|
||||
workspaceIds.add(row.workspaceId)
|
||||
accessibleByUserId.set(row.userId, workspaceIds)
|
||||
}
|
||||
|
||||
const pendingGrantRows =
|
||||
eligibleMemberEmails.length > 0
|
||||
? await db
|
||||
.select({
|
||||
email: invitation.email,
|
||||
workspaceId: invitationWorkspaceGrant.workspaceId,
|
||||
})
|
||||
.from(invitationWorkspaceGrant)
|
||||
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
|
||||
.where(
|
||||
and(
|
||||
inArray(invitationWorkspaceGrant.workspaceId, grantWorkspaceIds),
|
||||
inArray(invitation.email, eligibleMemberEmails),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
: []
|
||||
const pendingWorkspaceIdsByEmail = new Map<string, Set<string>>()
|
||||
for (const row of pendingGrantRows) {
|
||||
const email = row.email.toLowerCase()
|
||||
const workspaceIds = pendingWorkspaceIdsByEmail.get(email) ?? new Set<string>()
|
||||
workspaceIds.add(row.workspaceId)
|
||||
pendingWorkspaceIdsByEmail.set(email, workspaceIds)
|
||||
}
|
||||
|
||||
for (const email of eligibleMemberEmails) {
|
||||
const memberUserId = memberUserIdByEmail.get(email) as string
|
||||
const accessibleWorkspaceIds = accessibleByUserId.get(memberUserId)
|
||||
const pendingWorkspaceIds = pendingWorkspaceIdsByEmail.get(email)
|
||||
|
||||
const grantsNeeded = validGrants.filter(
|
||||
(grant) =>
|
||||
!accessibleWorkspaceIds?.has(grant.workspaceId) &&
|
||||
!pendingWorkspaceIds?.has(grant.workspaceId)
|
||||
)
|
||||
|
||||
if (grantsNeeded.length > 0) {
|
||||
memberWorkspaceInvites.push({ email, grants: grantsNeeded })
|
||||
} else {
|
||||
membersAlreadyCovered.push(email)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
membersAlreadyCovered.push(...memberEmails)
|
||||
}
|
||||
|
||||
if (emailsToInvite.length === 0 && memberWorkspaceInvites.length === 0) {
|
||||
const isSingleEmail = processedEmails.length === 1
|
||||
const pendingInvitationEmails = processedEmails.filter((email) =>
|
||||
pendingEmails.includes(email)
|
||||
)
|
||||
|
||||
if (isSingleEmail) {
|
||||
if (membersAlreadyCovered.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isBatch
|
||||
? 'Failed to send invitation. User already has access or a pending invitation to every selected workspace.'
|
||||
: 'Failed to send invitation. User is already a part of the organization.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (pendingInvitationEmails.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Failed to send invitation. A pending invitation already exists for this email.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isBatch
|
||||
? 'All emails are already members with access to the selected workspaces or have pending invitations.'
|
||||
: 'All emails are already members or have pending invitations.',
|
||||
details: {
|
||||
existingMembers: membersAlreadyCovered,
|
||||
pendingInvitations: pendingInvitationEmails,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const orgSubscription = await getOrganizationSubscription(organizationId)
|
||||
const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan)
|
||||
const seatValidation =
|
||||
enforceFixedSeats && emailsToInvite.length > 0
|
||||
? await validateSeatAvailability(organizationId, emailsToInvite.length)
|
||||
: null
|
||||
if (seatValidation && !seatValidation.canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: seatValidation.reason,
|
||||
seatInfo: {
|
||||
currentSeats: seatValidation.currentSeats,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats,
|
||||
seatsRequested: emailsToInvite.length,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [inviterRow] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const inviterName = inviterRow?.name || inviterRow?.email || 'A user'
|
||||
|
||||
const failedInvitations: Array<{ email: string; error: string }> = []
|
||||
|
||||
/**
|
||||
* Brand-new emails receive an organization invitation (with all selected
|
||||
* workspace grants) that still requires acceptance — accepting is what
|
||||
* joins them to the org and consumes a seat.
|
||||
*/
|
||||
const sentInvitations: Array<{ id: string; email: string; workspaceIds: string[] }> = []
|
||||
|
||||
for (const email of emailsToInvite) {
|
||||
try {
|
||||
const { invitationId, token } = await createPendingInvitation({
|
||||
kind: 'organization',
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
organizationId,
|
||||
membershipIntent: 'internal',
|
||||
role,
|
||||
grants: validGrants,
|
||||
})
|
||||
|
||||
const emailResult = await sendInvitationEmail({
|
||||
invitationId,
|
||||
token,
|
||||
kind: 'organization',
|
||||
email,
|
||||
inviterName,
|
||||
organizationId,
|
||||
organizationRole: role,
|
||||
grants: validGrants,
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error('Failed to send invitation email', {
|
||||
email,
|
||||
error: emailResult.error,
|
||||
})
|
||||
failedInvitations.push({
|
||||
email,
|
||||
error: emailResult.error || 'Unknown email delivery error',
|
||||
})
|
||||
await cancelPendingInvitation(invitationId)
|
||||
continue
|
||||
}
|
||||
|
||||
sentInvitations.push({
|
||||
id: invitationId,
|
||||
email,
|
||||
workspaceIds: validGrants.map((grant) => grant.workspaceId),
|
||||
})
|
||||
} catch (creationError) {
|
||||
logger.error('Failed to create invitation', {
|
||||
email,
|
||||
error: creationError,
|
||||
})
|
||||
failedInvitations.push({
|
||||
email,
|
||||
error: getErrorMessage(creationError, 'Failed to create invitation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing organization members are granted workspace access directly —
|
||||
* no invitation, no acceptance step. They are already in the org, so no
|
||||
* seat is consumed. The grant is idempotent and upgrades lower access.
|
||||
*/
|
||||
const directlyAdded: string[] = []
|
||||
|
||||
for (const memberInvite of memberWorkspaceInvites) {
|
||||
const memberUserId = memberUserIdByEmail.get(memberInvite.email)
|
||||
if (!memberUserId) continue
|
||||
|
||||
let addedAny = false
|
||||
let lastGrantError: string | null = null
|
||||
for (const grant of memberInvite.grants) {
|
||||
try {
|
||||
const grantResult = await grantWorkspaceAccessDirectly({
|
||||
userId: memberUserId,
|
||||
email: memberInvite.email,
|
||||
workspaceId: grant.workspaceId,
|
||||
workspaceName: workspaceNameById.get(grant.workspaceId) ?? 'a workspace',
|
||||
permission: grant.permission,
|
||||
organizationId,
|
||||
actorId: session.user.id,
|
||||
actorName: inviterName,
|
||||
actorEmail: session.user.email,
|
||||
request,
|
||||
})
|
||||
|
||||
if (grantResult.outcome === 'added') addedAny = true
|
||||
} catch (grantError) {
|
||||
logger.error('Failed to grant workspace access directly', {
|
||||
email: memberInvite.email,
|
||||
workspaceId: grant.workspaceId,
|
||||
error: grantError,
|
||||
})
|
||||
lastGrantError = getErrorMessage(grantError, 'Failed to add member to workspace')
|
||||
}
|
||||
}
|
||||
|
||||
if (addedAny) {
|
||||
directlyAdded.push(memberInvite.email)
|
||||
} else if (lastGrantError) {
|
||||
failedInvitations.push({ email: memberInvite.email, error: lastGrantError })
|
||||
}
|
||||
}
|
||||
|
||||
for (const inv of sentInvitations) {
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_INVITATION_CREATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: organizationEntry.name,
|
||||
description: `Invited ${inv.email} to organization as ${role}`,
|
||||
metadata: {
|
||||
invitationId: inv.id,
|
||||
targetEmail: inv.email,
|
||||
targetRole: role,
|
||||
isBatch,
|
||||
workspaceGrantCount: validGrants.length,
|
||||
enforcedFixedSeats: enforceFixedSeats,
|
||||
plan: orgSubscription?.plan ?? null,
|
||||
},
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
const totalInvitationsSent = sentInvitations.length
|
||||
const totalSucceeded = totalInvitationsSent + directlyAdded.length
|
||||
const responseData = {
|
||||
invitationsSent: totalInvitationsSent,
|
||||
invitedEmails: sentInvitations.map((inv) => inv.email),
|
||||
directlyAdded,
|
||||
directlyAddedCount: directlyAdded.length,
|
||||
failedInvitations,
|
||||
existingMembers: membersAlreadyCovered,
|
||||
pendingInvitations: processedEmails.filter(
|
||||
(email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email)
|
||||
),
|
||||
invalidEmails: invitationEmails.filter(
|
||||
(email) => !quickValidateEmail(normalizeEmail(email)).isValid
|
||||
),
|
||||
workspaceGrantsPerInvite: validGrants.length,
|
||||
...(seatValidation
|
||||
? {
|
||||
seatInfo: {
|
||||
seatsUsed: seatValidation.currentSeats + totalInvitationsSent,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats - totalInvitationsSent,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
const summaryParts: string[] = []
|
||||
if (totalInvitationsSent > 0) summaryParts.push(`${totalInvitationsSent} invitation(s) sent`)
|
||||
if (directlyAdded.length > 0) summaryParts.push(`${directlyAdded.length} member(s) added`)
|
||||
const summary = summaryParts.join(', ')
|
||||
|
||||
if (failedInvitations.length > 0 && totalSucceeded === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to send invitations.',
|
||||
message: 'No invitations could be delivered.',
|
||||
data: responseData,
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
if (failedInvitations.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Some invitations failed.',
|
||||
message: `${summary}, ${failedInvitations.length} failed`,
|
||||
data: responseData,
|
||||
},
|
||||
{ status: 207 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${summary || 'No changes'} successfully`,
|
||||
data: responseData,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof InvitationsNotAllowedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
logger.error('Failed to create organization invitations', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,528 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db, dbReplica } from '@sim/db'
|
||||
import { member, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organization'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
|
||||
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
removeExternalUserFromOrganizationWorkspaces,
|
||||
removeUserFromOrganization,
|
||||
WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('OrganizationMemberAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members/[memberId]
|
||||
* Get individual organization member details
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, memberId } = await params
|
||||
const url = new URL(request.url)
|
||||
const includeUsage = url.searchParams.get('include') === 'usage'
|
||||
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (userMember.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = userMember[0].role
|
||||
const hasAdminAccess = isOrgAdminRole(userRole)
|
||||
|
||||
const memberQuery = db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
const memberEntry = await memberQuery
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const canViewDetails = hasAdminAccess || session.user.id === memberId
|
||||
|
||||
if (!canViewDetails) {
|
||||
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
let memberData = memberEntry[0]
|
||||
|
||||
if (includeUsage && hasAdminAccess) {
|
||||
const usageData = await db
|
||||
.select({
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
lastPeriodCost: userStats.lastPeriodCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, memberId))
|
||||
.limit(1)
|
||||
|
||||
const computed = await getUserUsageData(memberId, dbReplica)
|
||||
|
||||
if (usageData.length > 0) {
|
||||
// currentPeriodCost is only a baseline; add this member's attributed
|
||||
// usage_log for the period. (getUserUsageData returns the org POOL for
|
||||
// org-scoped members, so it can't supply the per-member figure.)
|
||||
const memberLedger =
|
||||
(
|
||||
await getOrgMemberLedgerByUser(
|
||||
organizationId,
|
||||
computed.billingPeriodStart && computed.billingPeriodEnd
|
||||
? { start: computed.billingPeriodStart, end: computed.billingPeriodEnd }
|
||||
: null,
|
||||
dbReplica
|
||||
)
|
||||
).get(memberId) ?? 0
|
||||
memberData = {
|
||||
...memberData,
|
||||
usage: {
|
||||
...usageData[0],
|
||||
currentPeriodCost: (
|
||||
Number(usageData[0].currentPeriodCost ?? 0) + memberLedger
|
||||
).toString(),
|
||||
billingPeriodStart: computed.billingPeriodStart,
|
||||
billingPeriodEnd: computed.billingPeriodEnd,
|
||||
},
|
||||
} as typeof memberData & {
|
||||
usage: (typeof usageData)[0] & {
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: memberData,
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization member', {
|
||||
organizationId: (await params).id,
|
||||
memberId: (await params).memberId,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]/members/[memberId]
|
||||
* Update organization member role
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateOrganizationMemberRoleContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId, memberId } = parsed.data.params
|
||||
const { role } = parsed.data.body
|
||||
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (userMember.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOrgAdminRole(userMember[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const targetMember = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
userId: member.userId,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (targetMember[0].role === 'owner') {
|
||||
return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (role === 'owner') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const updatedMember = await db
|
||||
.update(member)
|
||||
.set({ role })
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId)))
|
||||
.returning()
|
||||
|
||||
if (updatedMember.length === 0) {
|
||||
return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 })
|
||||
}
|
||||
|
||||
logger.info('Organization member role updated', {
|
||||
organizationId,
|
||||
memberId,
|
||||
newRole: role,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Changed role for member ${memberId} to ${role}`,
|
||||
metadata: {
|
||||
targetUserId: memberId,
|
||||
targetEmail: targetMember[0].email ?? undefined,
|
||||
targetName: targetMember[0].name ?? undefined,
|
||||
changes: [{ field: 'role', from: targetMember[0].role, to: role }],
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'org_member_role_changed',
|
||||
{ organization_id: organizationId, new_role: role },
|
||||
{ groups: { organization: organizationId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Member role updated successfully',
|
||||
data: {
|
||||
id: updatedMember[0].id,
|
||||
userId: updatedMember[0].userId,
|
||||
role: updatedMember[0].role,
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization member role', {
|
||||
organizationId: (await context.params).id,
|
||||
memberId: (await context.params).memberId,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/organizations/[id]/members/[memberId]
|
||||
* Remove member from organization
|
||||
*/
|
||||
export const DELETE = withRouteHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; memberId: string }> }
|
||||
) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, memberId: targetUserId } = await params
|
||||
|
||||
const userMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (userMember.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const canRemoveMembers =
|
||||
isOrgAdminRole(userMember[0].role) || session.user.id === targetUserId
|
||||
|
||||
if (!canRemoveMembers) {
|
||||
return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const targetMember = await db
|
||||
.select({ id: member.id, role: member.role, email: user.email, name: user.name })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId)))
|
||||
.limit(1)
|
||||
|
||||
if (targetMember.length === 0) {
|
||||
const [targetUser] = await db
|
||||
.select({ id: user.id, email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, targetUserId))
|
||||
.limit(1)
|
||||
|
||||
if (!targetUser) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const externalResult = await removeExternalUserFromOrganizationWorkspaces({
|
||||
userId: targetUserId,
|
||||
organizationId,
|
||||
})
|
||||
|
||||
if (!externalResult.success) {
|
||||
const error = externalResult.error || 'External workspace member not found'
|
||||
const status =
|
||||
error === 'External workspace member not found'
|
||||
? 404
|
||||
: error === 'User is an organization member'
|
||||
? 409
|
||||
: error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR
|
||||
? 400
|
||||
: 500
|
||||
|
||||
return NextResponse.json({ error }, { status })
|
||||
}
|
||||
|
||||
logger.info('External workspace member removed from organization workspaces', {
|
||||
organizationId,
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
|
||||
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
|
||||
credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked,
|
||||
pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Removed external workspace member ${targetUserId} from organization`,
|
||||
metadata: {
|
||||
targetUserId,
|
||||
targetEmail: targetUser.email ?? undefined,
|
||||
targetName: targetUser.name ?? undefined,
|
||||
membershipType: 'external',
|
||||
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
|
||||
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
|
||||
credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked,
|
||||
pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'org_member_removed',
|
||||
{ organization_id: organizationId, is_self_removal: session.user.id === targetUserId },
|
||||
{ groups: { organization: organizationId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'External member removed successfully',
|
||||
data: {
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
removedAt: new Date().toISOString(),
|
||||
membershipType: 'external',
|
||||
workspaceAccessRevoked: externalResult.workspaceAccessRevoked,
|
||||
permissionGroupsRevoked: externalResult.permissionGroupsRevoked,
|
||||
credentialMembershipsRevoked: externalResult.credentialMembershipsRevoked,
|
||||
pendingInvitationsCancelled: externalResult.pendingInvitationsCancelled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await removeUserFromOrganization({
|
||||
userId: targetUserId,
|
||||
organizationId,
|
||||
memberId: targetMember[0].id,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error === 'Cannot remove organization owner') {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
if (result.error === 'Member not found') {
|
||||
return NextResponse.json({ error: result.error }, { status: 404 })
|
||||
}
|
||||
if (result.error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
return NextResponse.json({ error: result.error }, { status: 500 })
|
||||
}
|
||||
|
||||
let seatReduction: Awaited<ReturnType<typeof reconcileOrganizationSeats>> | null = null
|
||||
try {
|
||||
seatReduction = await reconcileOrganizationSeats({
|
||||
organizationId,
|
||||
reason: 'member-removed',
|
||||
actorId: session.user.id,
|
||||
})
|
||||
} catch (seatError) {
|
||||
logger.error('Failed to reduce seats after member removal', {
|
||||
organizationId,
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
error: seatError,
|
||||
})
|
||||
seatReduction = {
|
||||
changed: false,
|
||||
reason: 'Failed to reduce seats after member removal',
|
||||
}
|
||||
}
|
||||
|
||||
if (session.user.id === targetUserId) {
|
||||
try {
|
||||
await setActiveOrganizationForCurrentSession(null)
|
||||
} catch (clearError) {
|
||||
logger.warn('Failed to clear active organization after self-removal', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error: clearError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Organization member removed', {
|
||||
organizationId,
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
wasSelfRemoval: session.user.id === targetUserId,
|
||||
billingActions: result.billingActions,
|
||||
seatReduction,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description:
|
||||
session.user.id === targetUserId
|
||||
? 'Left the organization'
|
||||
: `Removed member ${targetUserId} from organization`,
|
||||
metadata: {
|
||||
targetUserId,
|
||||
targetEmail: targetMember[0].email ?? undefined,
|
||||
targetName: targetMember[0].name ?? undefined,
|
||||
wasSelfRemoval: session.user.id === targetUserId,
|
||||
seatReduction,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'org_member_removed',
|
||||
{ organization_id: organizationId, is_self_removal: session.user.id === targetUserId },
|
||||
{ groups: { organization: organizationId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message:
|
||||
session.user.id === targetUserId
|
||||
? 'You have left the organization'
|
||||
: 'Member removed successfully',
|
||||
data: {
|
||||
removedMemberId: targetUserId,
|
||||
removedBy: session.user.id,
|
||||
removedAt: new Date().toISOString(),
|
||||
seatReduction,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove organization member', {
|
||||
organizationId: (await params).id,
|
||||
memberId: (await params).memberId,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createMockRequest, createSession } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockIsOrganizationOwnerOrAdmin,
|
||||
mockGetOrgMemberUsageLimit,
|
||||
mockGetOrgMemberWorkspaceUsage,
|
||||
mockSetOrgMemberUsageLimit,
|
||||
mockGetOrganizationSubscription,
|
||||
mockFlags,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockIsOrganizationOwnerOrAdmin: vi.fn(),
|
||||
mockGetOrgMemberUsageLimit: vi.fn(),
|
||||
mockGetOrgMemberWorkspaceUsage: vi.fn(),
|
||||
mockSetOrgMemberUsageLimit: vi.fn(),
|
||||
mockGetOrganizationSubscription: vi.fn(),
|
||||
mockFlags: { isHosted: true },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/billing/core/organization', () => ({
|
||||
isOrganizationOwnerOrAdmin: mockIsOrganizationOwnerOrAdmin,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organizations/member-limits', () => ({
|
||||
getOrgMemberUsageLimit: mockGetOrgMemberUsageLimit,
|
||||
getOrgMemberWorkspaceUsage: mockGetOrgMemberWorkspaceUsage,
|
||||
setOrgMemberUsageLimit: mockSetOrgMemberUsageLimit,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/billing', () => ({
|
||||
getOrganizationSubscription: mockGetOrganizationSubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
get isHosted() {
|
||||
return mockFlags.isHosted
|
||||
},
|
||||
}))
|
||||
|
||||
import { GET, PUT } from '@/app/api/organizations/[id]/members/[memberId]/usage-limit/route'
|
||||
|
||||
function context() {
|
||||
return { params: Promise.resolve({ id: 'org-1', memberId: 'user-2' }) }
|
||||
}
|
||||
|
||||
function putRequest(body: unknown) {
|
||||
return createMockRequest('PUT', body)
|
||||
}
|
||||
|
||||
function getRequest() {
|
||||
return createMockRequest('GET')
|
||||
}
|
||||
|
||||
describe('GET /api/organizations/[id]/members/[memberId]/usage-limit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFlags.isHosted = true
|
||||
mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' }))
|
||||
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true)
|
||||
mockGetOrgMemberWorkspaceUsage.mockResolvedValue(1) // $1 -> 200 credits
|
||||
mockGetOrgMemberUsageLimit.mockResolvedValue(2) // $2 -> 400 credits
|
||||
mockGetOrganizationSubscription.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns 401 without a session', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
const res = await GET(getRequest(), context())
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when not hosted', async () => {
|
||||
mockFlags.isHosted = false
|
||||
const res = await GET(getRequest(), context())
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 403 for non-admin callers', async () => {
|
||||
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false)
|
||||
const res = await GET(getRequest(), context())
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns credits used and limit converted to credits', async () => {
|
||||
const res = await GET(getRequest(), context())
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
creditsUsed: 200,
|
||||
creditLimit: 400,
|
||||
billingInterval: 'month',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null creditLimit when no cap is set', async () => {
|
||||
mockGetOrgMemberUsageLimit.mockResolvedValue(null)
|
||||
const res = await GET(getRequest(), context())
|
||||
const body = await res.json()
|
||||
expect(body.data.creditLimit).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a yearly billing interval from subscription metadata', async () => {
|
||||
mockGetOrganizationSubscription.mockResolvedValue({ metadata: { billingInterval: 'year' } })
|
||||
const res = await GET(getRequest(), context())
|
||||
const body = await res.json()
|
||||
expect(body.data.billingInterval).toBe('year')
|
||||
})
|
||||
|
||||
it('prefers the billing_interval column when metadata lacks it', async () => {
|
||||
mockGetOrganizationSubscription.mockResolvedValue({ billingInterval: 'year', metadata: {} })
|
||||
const res = await GET(getRequest(), context())
|
||||
const body = await res.json()
|
||||
expect(body.data.billingInterval).toBe('year')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFlags.isHosted = true
|
||||
mockGetSession.mockResolvedValue(createSession({ userId: 'admin-1' }))
|
||||
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(true)
|
||||
mockSetOrgMemberUsageLimit.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns 404 when not hosted', async () => {
|
||||
mockFlags.isHosted = false
|
||||
const res = await PUT(putRequest({ creditLimit: 400 }), context())
|
||||
expect(res.status).toBe(404)
|
||||
expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 403 for non-admin callers', async () => {
|
||||
mockIsOrganizationOwnerOrAdmin.mockResolvedValue(false)
|
||||
const res = await PUT(putRequest({ creditLimit: 400 }), context())
|
||||
expect(res.status).toBe(403)
|
||||
expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists the limit as dollars (credits / 200) and audits', async () => {
|
||||
const res = await PUT(putRequest({ creditLimit: 400 }), context())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', 2, 'admin-1')
|
||||
expect(auditMock.recordAudit).toHaveBeenCalledTimes(1)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'Member credit limit updated successfully',
|
||||
data: { creditLimit: 400 },
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the cap when creditLimit is null', async () => {
|
||||
const res = await PUT(putRequest({ creditLimit: null }), context())
|
||||
expect(res.status).toBe(200)
|
||||
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1')
|
||||
})
|
||||
|
||||
it('rejects a negative credit limit with 400', async () => {
|
||||
const res = await PUT(putRequest({ creditLimit: -5 }), context())
|
||||
expect(res.status).toBe(400)
|
||||
expect(mockSetOrgMemberUsageLimit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getOrganizationMemberUsageLimitContract,
|
||||
updateOrganizationMemberUsageLimitContract,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { resolveBillingInterval } from '@/lib/billing/core/subscription'
|
||||
import { creditsToDollars, dollarsToCredits } from '@/lib/billing/credits/conversion'
|
||||
import {
|
||||
getOrgMemberUsageLimit,
|
||||
getOrgMemberWorkspaceUsage,
|
||||
setOrgMemberUsageLimit,
|
||||
} from '@/lib/billing/organizations/member-limits'
|
||||
import { isHosted } from '@/lib/core/config/env-flags'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('OrgMemberUsageLimitAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members/[memberId]/usage-limit
|
||||
*
|
||||
* Returns the member's current-period credits used inside the org's workspaces
|
||||
* and their per-member credit cap (both in credits). Owner/admin only and
|
||||
* hosted-only (the feature is meaningless where Sim does not own the DB/billing).
|
||||
* `memberId` is the target user id, so external members are supported.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!isHosted) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(getOrganizationMemberUsageLimitContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId, memberId } = parsed.data.params
|
||||
|
||||
const hasAccess = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [usage, limitDollars, orgSubscription] = await Promise.all([
|
||||
getOrgMemberWorkspaceUsage(organizationId, memberId),
|
||||
getOrgMemberUsageLimit(organizationId, memberId),
|
||||
getOrganizationSubscription(organizationId),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
creditsUsed: dollarsToCredits(usage),
|
||||
creditLimit: limitDollars === null ? null : dollarsToCredits(limitDollars),
|
||||
billingInterval: resolveBillingInterval(orgSubscription),
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]/members/[memberId]/usage-limit
|
||||
*
|
||||
* Sets (or clears, when `creditLimit` is null) the member's per-org credit cap.
|
||||
* Owner/admin only and hosted-only. The target need not be an org `member` row,
|
||||
* so external members are supported.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; memberId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!isHosted) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateOrganizationMemberUsageLimitContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId, memberId } = parsed.data.params
|
||||
const { creditLimit } = parsed.data.body
|
||||
|
||||
const hasAccess = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const limitDollars = creditLimit === null ? null : creditsToDollars(creditLimit)
|
||||
await setOrgMemberUsageLimit(organizationId, memberId, limitDollars, session.user.id)
|
||||
|
||||
logger.info('Updated per-member usage limit', {
|
||||
organizationId,
|
||||
memberId,
|
||||
creditLimit,
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_MEMBER_USAGE_LIMIT_CHANGED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description:
|
||||
creditLimit === null
|
||||
? `Cleared credit limit for member ${memberId}`
|
||||
: `Set credit limit for member ${memberId} to ${creditLimit} credits`,
|
||||
metadata: {
|
||||
targetUserId: memberId,
|
||||
creditLimit,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Member credit limit updated successfully',
|
||||
data: { creditLimit },
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,380 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
subscription as subscriptionTable,
|
||||
user,
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
inviteOrganizationMemberContract,
|
||||
organizationMemberQuerySchema,
|
||||
organizationParamsSchema,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
|
||||
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
cancelPendingInvitation,
|
||||
createPendingInvitation,
|
||||
sendInvitationEmail,
|
||||
} from '@/lib/invitations/send'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
const logger = createLogger('OrganizationMembersAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/members
|
||||
* Get organization members with optional usage data
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const paramsResult = organizationParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id: organizationId } = paramsResult.data
|
||||
const queryResult = organizationMemberQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams.entries())
|
||||
)
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(queryResult.error, 'Invalid query parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const includeUsage = queryResult.data.include === 'usage'
|
||||
|
||||
// Verify user has access to this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = isOrgAdminRole(userRole)
|
||||
|
||||
// Get organization members
|
||||
const query = db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
// Include usage data if requested and user has admin access
|
||||
if (includeUsage && hasAdminAccess) {
|
||||
const base = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
currentPeriodCost: userStats.currentPeriodCost,
|
||||
currentUsageLimit: userStats.currentUsageLimit,
|
||||
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.leftJoin(userStats, eq(user.id, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
// The billing period is the same for every member — it comes from
|
||||
// whichever subscription covers them. Fetch once and attach to
|
||||
// every row instead of calling `getUserUsageData` per-member,
|
||||
// which would run an O(N) pooled query for each of N rows.
|
||||
const [orgSub] = await db
|
||||
.select({
|
||||
periodStart: subscriptionTable.periodStart,
|
||||
periodEnd: subscriptionTable.periodEnd,
|
||||
})
|
||||
.from(subscriptionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, organizationId),
|
||||
inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const billingPeriodStart = orgSub?.periodStart ?? null
|
||||
const billingPeriodEnd = orgSub?.periodEnd ?? null
|
||||
|
||||
// currentPeriodCost is only a baseline; add each member's attributed
|
||||
// usage_log for the period (batched, one query) so the roster shows real
|
||||
// usage rather than the frozen baseline.
|
||||
const usageByUser = await getOrgMemberLedgerByUser(
|
||||
organizationId,
|
||||
billingPeriodStart && billingPeriodEnd
|
||||
? { start: billingPeriodStart, end: billingPeriodEnd }
|
||||
: null
|
||||
)
|
||||
|
||||
const membersWithUsage = base.map((row) => ({
|
||||
...row,
|
||||
currentPeriodCost: (
|
||||
Number(row.currentPeriodCost ?? 0) + (usageByUser.get(row.userId) ?? 0)
|
||||
).toString(),
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: membersWithUsage,
|
||||
total: membersWithUsage.length,
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
}
|
||||
|
||||
const members = await query
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: members,
|
||||
total: members.length,
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization members', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/organizations/[id]/members
|
||||
* Invite new member to organization
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(inviteOrganizationMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
await validateInvitationsAllowed(session.user.id, { organizationId })
|
||||
|
||||
const { email, role = 'member' } = parsed.data.body
|
||||
|
||||
// Validate and normalize email
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
const validation = quickValidateEmail(normalizedEmail)
|
||||
if (!validation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: validation.reason || 'Invalid email format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOrgAdminRole(memberEntry[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check seat availability
|
||||
const seatValidation = await validateSeatAvailability(organizationId, 1)
|
||||
if (!seatValidation.canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot invite teammate. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`,
|
||||
details: seatValidation,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingUser = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.email, normalizedEmail))
|
||||
.limit(1)
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.organizationId, organizationId), eq(member.userId, existingUser[0].id))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing pending invitation
|
||||
const existingInvitation = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, organizationId),
|
||||
eq(invitation.email, normalizedEmail),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingInvitation.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Pending invitation already exists for this email' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { invitationId, token } = await createPendingInvitation({
|
||||
kind: 'organization',
|
||||
email: normalizedEmail,
|
||||
inviterId: session.user.id,
|
||||
organizationId,
|
||||
role,
|
||||
grants: [],
|
||||
})
|
||||
|
||||
const [inviterRow] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const inviterName = inviterRow?.name || inviterRow?.email || 'A user'
|
||||
|
||||
const emailResult = await sendInvitationEmail({
|
||||
invitationId,
|
||||
token,
|
||||
kind: 'organization',
|
||||
email: normalizedEmail,
|
||||
inviterName,
|
||||
organizationId,
|
||||
organizationRole: role,
|
||||
grants: [],
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error('Failed to send organization invitation email', {
|
||||
email: normalizedEmail,
|
||||
invitationId,
|
||||
error: emailResult.error,
|
||||
})
|
||||
await cancelPendingInvitation(invitationId)
|
||||
return NextResponse.json(
|
||||
{ error: emailResult.error || 'Failed to send invitation email' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Member invitation sent', {
|
||||
email: normalizedEmail,
|
||||
organizationId,
|
||||
invitationId,
|
||||
role,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_INVITATION_CREATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Invited ${normalizedEmail} to organization as ${role}`,
|
||||
metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Invitation sent to ${normalizedEmail}`,
|
||||
data: {
|
||||
invitationId,
|
||||
email: normalizedEmail,
|
||||
role,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof InvitationsNotAllowedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
logger.error('Failed to invite organization member', {
|
||||
organizationId: (await context.params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, permissionGroupMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
|
||||
import {
|
||||
acquirePermissionGroupOrgLock,
|
||||
authorizeOrgAccessControl,
|
||||
findScopeConflicts,
|
||||
formatScopeConflictError,
|
||||
getGroupWorkspaces,
|
||||
loadGroupInOrganization,
|
||||
type ScopeConflict,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('OrganizationPermissionGroupBulkMembers')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await context.params
|
||||
|
||||
// Populated inside the transaction when a scope conflict is detected, so the
|
||||
// catch can format the 409 after the rollback.
|
||||
let scopeConflicts: ScopeConflict[] = []
|
||||
|
||||
try {
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(bulkAddPermissionGroupMembersContract, req, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { userIds, addAllOrganizationMembers } = parsed.data.body
|
||||
|
||||
let targetUserIds: string[] = []
|
||||
|
||||
if (addAllOrganizationMembers) {
|
||||
const orgMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
targetUserIds = Array.from(new Set(orgMembers.map((m) => m.userId)))
|
||||
} else if (userIds && userIds.length > 0) {
|
||||
const uniqueUserIds = Array.from(new Set(userIds))
|
||||
const validMembers = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.organizationId, organizationId), inArray(member.userId, uniqueUserIds))
|
||||
)
|
||||
|
||||
targetUserIds = Array.from(new Set(validMembers.map((m) => m.userId)))
|
||||
}
|
||||
|
||||
if (targetUserIds.length === 0) {
|
||||
return NextResponse.json({ added: 0, skipped: 0 })
|
||||
}
|
||||
|
||||
const { addedUserIds } = await db.transaction(async (tx) => {
|
||||
// Serialize all permission-group writes for this org so the conflict
|
||||
// check and inserts are atomic against concurrent adds or scope changes.
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
// Re-read the group under the lock: a concurrent scope change may have
|
||||
// changed its workspaces since the pre-transaction load, so the conflict
|
||||
// check uses one consistent snapshot.
|
||||
const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
|
||||
if (!lockedGroup) {
|
||||
throw new Error('GROUP_NOT_FOUND')
|
||||
}
|
||||
|
||||
// Bulk add is all-or-nothing for conflicts: if any selected user is
|
||||
// already an explicit member of another group sharing one of this group's
|
||||
// workspaces, add nobody and surface the conflict so the admin can fix the
|
||||
// selection. Members already in this group are no-ops.
|
||||
const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
|
||||
const conflicts = await findScopeConflicts(
|
||||
{
|
||||
organizationId,
|
||||
excludeGroupId: id,
|
||||
workspaceIds: groupWorkspaceIds,
|
||||
candidateUserIds: targetUserIds,
|
||||
},
|
||||
tx
|
||||
)
|
||||
if (conflicts.length > 0) {
|
||||
scopeConflicts = conflicts
|
||||
throw new Error('SCOPE_CONFLICT')
|
||||
}
|
||||
|
||||
const existingInGroup = await tx
|
||||
.select({ userId: permissionGroupMember.userId })
|
||||
.from(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.permissionGroupId, id),
|
||||
inArray(permissionGroupMember.userId, targetUserIds)
|
||||
)
|
||||
)
|
||||
const alreadyInThisGroup = new Set(existingInGroup.map((m) => m.userId))
|
||||
|
||||
const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid))
|
||||
|
||||
if (usersToAdd.length === 0) {
|
||||
return { addedUserIds: [] as string[] }
|
||||
}
|
||||
|
||||
const newMembers = usersToAdd.map((userId) => ({
|
||||
id: generateId(),
|
||||
permissionGroupId: id,
|
||||
organizationId,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
}))
|
||||
|
||||
await tx.insert(permissionGroupMember).values(newMembers)
|
||||
|
||||
return { addedUserIds: usersToAdd }
|
||||
})
|
||||
|
||||
const skipped = targetUserIds.length - addedUserIds.length
|
||||
|
||||
if (addedUserIds.length === 0) {
|
||||
return NextResponse.json({ added: 0, skipped })
|
||||
}
|
||||
|
||||
logger.info('Bulk added members to permission group', {
|
||||
permissionGroupId: id,
|
||||
organizationId,
|
||||
addedCount: addedUserIds.length,
|
||||
skipped,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: id,
|
||||
resourceName: group.name,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Bulk added ${addedUserIds.length} member(s) to permission group "${group.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
permissionGroupId: id,
|
||||
addedUserIds,
|
||||
skipped,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ added: addedUserIds.length, skipped })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
|
||||
return NextResponse.json(
|
||||
{ error: formatScopeConflictError(scopeConflicts) },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (
|
||||
getPostgresErrorCode(error) === '23505' &&
|
||||
getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'One or more users were concurrently added to this group. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
// Advisory lock wait exceeded (lock_timeout) — transient contention.
|
||||
if (getPostgresErrorCode(error) === '55P03') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This group is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
logger.error('Error bulk adding members to permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to add members' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroupMember, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/types'
|
||||
import { isOrganizationMember } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
type AllMembersConflict,
|
||||
acquirePermissionGroupOrgLock,
|
||||
authorizeOrgAccessControl,
|
||||
findAllMembersWorkspaceConflict,
|
||||
findScopeConflicts,
|
||||
formatAllMembersConflictError,
|
||||
formatScopeConflictError,
|
||||
getGroupWorkspaces,
|
||||
loadGroupInOrganization,
|
||||
type ScopeConflict,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('OrganizationPermissionGroupMembers')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const members = await db
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
userId: permissionGroupMember.userId,
|
||||
assignedAt: permissionGroupMember.assignedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
|
||||
.where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
|
||||
return NextResponse.json({ members })
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await context.params
|
||||
|
||||
// Populated inside the transaction when a scope conflict is detected, so the
|
||||
// catch can format the 409 after the rollback.
|
||||
let scopeConflicts: ScopeConflict[] = []
|
||||
|
||||
try {
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(addPermissionGroupMemberContract, req, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { userId } = parsed.data.body
|
||||
|
||||
const isMember = await isOrganizationMember(userId, organizationId)
|
||||
if (!isMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is not a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const newMember = await db.transaction(async (tx) => {
|
||||
// Serialize all permission-group writes for this org so the conflict
|
||||
// check and insert are atomic. Without it, two concurrent adds (or a
|
||||
// concurrent scope change) could both pass findScopeConflicts and place
|
||||
// the user in two groups that overlap on a workspace.
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
// Re-read the group under the lock: a concurrent scope change may have
|
||||
// changed its workspaces since the pre-transaction load, so the conflict
|
||||
// check uses one consistent snapshot.
|
||||
const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
|
||||
if (!lockedGroup) {
|
||||
throw new Error('GROUP_NOT_FOUND')
|
||||
}
|
||||
|
||||
const [existingInGroup] = await tx
|
||||
.select({ id: permissionGroupMember.id })
|
||||
.from(permissionGroupMember)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.permissionGroupId, id),
|
||||
eq(permissionGroupMember.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingInGroup) {
|
||||
throw new Error('ALREADY_IN_GROUP')
|
||||
}
|
||||
|
||||
// A user may belong to multiple groups, but only one may govern any given
|
||||
// workspace. Reject when the user is already an explicit member of another
|
||||
// group that shares one of this group's workspaces.
|
||||
const groupWorkspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
|
||||
const conflicts = await findScopeConflicts(
|
||||
{
|
||||
organizationId,
|
||||
excludeGroupId: id,
|
||||
workspaceIds: groupWorkspaceIds,
|
||||
candidateUserIds: [userId],
|
||||
},
|
||||
tx
|
||||
)
|
||||
if (conflicts.length > 0) {
|
||||
scopeConflicts = conflicts
|
||||
throw new Error('SCOPE_CONFLICT')
|
||||
}
|
||||
|
||||
const memberData = {
|
||||
id: generateId(),
|
||||
permissionGroupId: id,
|
||||
organizationId,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
assignedAt: new Date(),
|
||||
}
|
||||
|
||||
await tx.insert(permissionGroupMember).values(memberData)
|
||||
return memberData
|
||||
})
|
||||
|
||||
logger.info('Added member to permission group', {
|
||||
permissionGroupId: id,
|
||||
organizationId,
|
||||
userId,
|
||||
assignedBy: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: id,
|
||||
resourceName: group.name,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Added member ${userId} to permission group "${group.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
targetUserId: userId,
|
||||
permissionGroupId: id,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ member: newMember }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
if (error instanceof Error && error.message === 'ALREADY_IN_GROUP') {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already in this permission group' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
|
||||
return NextResponse.json(
|
||||
{ error: formatScopeConflictError(scopeConflicts) },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (
|
||||
getPostgresErrorCode(error) === '23505' &&
|
||||
getPostgresConstraintName(error) === PERMISSION_GROUP_MEMBER_CONSTRAINTS.groupUser
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User is already in this permission group' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
// Advisory lock wait exceeded (lock_timeout) — transient contention.
|
||||
if (getPostgresErrorCode(error) === '55P03') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This group is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
logger.error('Error adding member to permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to add member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const memberId = searchParams.get('memberId')
|
||||
|
||||
if (!memberId) {
|
||||
return NextResponse.json({ error: 'memberId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Populated inside the transaction when an all-members scope conflict is
|
||||
// detected, so the catch can format the 409 after the rollback.
|
||||
let allMembersConflict: AllMembersConflict | null = null
|
||||
|
||||
try {
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const memberToRemove = await db.transaction(async (tx) => {
|
||||
// Serialize permission-group writes for this org so the last-member check
|
||||
// and the delete commit atomically: removing the last member turns a
|
||||
// workspace group into an all-members group, which is unique per workspace.
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
const lockedGroup = await loadGroupInOrganization(id, organizationId, tx)
|
||||
if (!lockedGroup) {
|
||||
throw new Error('GROUP_NOT_FOUND')
|
||||
}
|
||||
|
||||
const [member] = await tx
|
||||
.select({
|
||||
id: permissionGroupMember.id,
|
||||
userId: permissionGroupMember.userId,
|
||||
email: user.email,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(user, eq(permissionGroupMember.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.id, memberId),
|
||||
eq(permissionGroupMember.permissionGroupId, id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!member) {
|
||||
throw new Error('MEMBER_NOT_FOUND')
|
||||
}
|
||||
|
||||
if (!lockedGroup.isDefault) {
|
||||
const [memberCountRow] = await tx
|
||||
.select({ value: count() })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
if ((memberCountRow?.value ?? 0) <= 1) {
|
||||
const workspaceIds = (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
|
||||
const conflict = await findAllMembersWorkspaceConflict(
|
||||
{ organizationId, excludeGroupId: id, workspaceIds },
|
||||
tx
|
||||
)
|
||||
if (conflict) {
|
||||
allMembersConflict = conflict
|
||||
throw new Error('ALL_MEMBERS_CONFLICT')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId))
|
||||
return member
|
||||
})
|
||||
|
||||
logger.info('Removed member from permission group', {
|
||||
permissionGroupId: id,
|
||||
organizationId,
|
||||
memberId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: id,
|
||||
resourceName: group.name,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
description: `Removed member ${memberToRemove.userId} from permission group "${group.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
targetUserId: memberToRemove.userId,
|
||||
targetEmail: memberToRemove.email ?? undefined,
|
||||
memberId,
|
||||
permissionGroupId: id,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
if (error instanceof Error && error.message === 'MEMBER_NOT_FOUND') {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'ALL_MEMBERS_CONFLICT' &&
|
||||
allMembersConflict
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: formatAllMembersConflictError(allMembersConflict) },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (getPostgresErrorCode(error) === '55P03') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This group is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
logger.error('Error removing member from permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,408 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
PERMISSION_GROUP_CONSTRAINTS,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import {
|
||||
type AllMembersConflict,
|
||||
acquirePermissionGroupOrgLock,
|
||||
authorizeOrgAccessControl,
|
||||
findAllMembersWorkspaceConflict,
|
||||
findScopeConflicts,
|
||||
findWorkspacesNotInOrganization,
|
||||
formatAllMembersConflictError,
|
||||
formatScopeConflictError,
|
||||
getGroupWorkspaces,
|
||||
loadGroupInOrganization,
|
||||
type ScopeConflict,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('OrganizationPermissionGroup')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaces = group.isDefault ? [] : await getGroupWorkspaces(id)
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
workspaces,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await context.params
|
||||
|
||||
// Populated inside the transaction when a scope conflict is detected, so the
|
||||
// catch can format the 409 after the rollback.
|
||||
let scopeConflicts: ScopeConflict[] = []
|
||||
let allMembersConflict: AllMembersConflict | null = null
|
||||
|
||||
try {
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updatePermissionGroupContract, req, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const updates = parsed.data.body
|
||||
|
||||
if (updates.name) {
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.name, updates.name)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0 && existingGroup[0].id !== id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const currentConfig = parsePermissionGroupConfig(group.config)
|
||||
const newConfig: PermissionGroupConfig = updates.config
|
||||
? { ...currentConfig, ...updates.config }
|
||||
: currentConfig
|
||||
|
||||
// Demoting the org default with no new scope: it becomes a non-default
|
||||
// group with no workspaces (inert) until an admin re-scopes it. The client
|
||||
// sends only `isDefault: false`, so this never forwards a workspace list.
|
||||
const demotingDefaultToInert =
|
||||
group.isDefault && updates.isDefault === false && updates.workspaceIds === undefined
|
||||
|
||||
// "Org-wide" is definitionally `isDefault` (the default group), so the
|
||||
// effective scope follows it: a default group targets no specific
|
||||
// workspaces; a non-default group targets its `workspaceIds`.
|
||||
const effectiveIsDefault =
|
||||
updates.isDefault !== undefined ? updates.isDefault : group.isDefault
|
||||
|
||||
// Scope is rewritten when the group is promoted to default, demoted to
|
||||
// inert, or handed an explicit workspace list.
|
||||
const scopeProvided =
|
||||
demotingDefaultToInert || updates.workspaceIds !== undefined || updates.isDefault === true
|
||||
|
||||
// The default group governs every workspace, so it can't also name specific
|
||||
// ones. The contract rejects `isDefault: true` + workspaceIds, but a direct
|
||||
// API caller can still send workspaceIds against a group that is already the
|
||||
// default — reject rather than silently dropping them.
|
||||
if (effectiveIsDefault && updates.workspaceIds !== undefined) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'The default group governs all workspaces and cannot target specific workspaces',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve and validate explicitly-provided workspaceIds before the
|
||||
// transaction. When the request omits them for a specific-scope group
|
||||
// ("keep current"), they're read under the lock instead (see below) so the
|
||||
// conflict check and the write share one consistent snapshot.
|
||||
let providedWorkspaceIds: string[] | null = null
|
||||
if (!effectiveIsDefault && updates.workspaceIds !== undefined) {
|
||||
// Zero workspaces is allowed on update: the group then governs nothing
|
||||
// (the resolver inner-joins on the link table, so an empty group never
|
||||
// matches any workspace). No "at least one" floor here.
|
||||
providedWorkspaceIds = Array.from(new Set(updates.workspaceIds))
|
||||
const invalid = await findWorkspacesNotInOrganization(providedWorkspaceIds, organizationId)
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One or more selected workspaces do not belong to this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// For a specific-scope group the target workspaces are the request's
|
||||
// explicit ids, or — when omitted ("keep current") — the group's current
|
||||
// workspaces read under the lock so the conflict check and write share
|
||||
// one snapshot.
|
||||
let resolvedWorkspaceIds: string[] = []
|
||||
|
||||
// When the scope changes, serialize against other permission-group writes
|
||||
// for this org and re-check membership conflicts atomically with the
|
||||
// write, so a concurrent member add (or scope change) can't slip a user
|
||||
// into two groups that overlap on a workspace.
|
||||
if (scopeProvided) {
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
if (!effectiveIsDefault) {
|
||||
// May resolve to an empty list — a non-default group is allowed to
|
||||
// target zero workspaces (governs nothing). The write below deletes
|
||||
// the old links and inserts none.
|
||||
resolvedWorkspaceIds =
|
||||
providedWorkspaceIds ?? (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
|
||||
}
|
||||
|
||||
const members = await tx
|
||||
.select({ userId: permissionGroupMember.userId })
|
||||
.from(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
const conflicts = await findScopeConflicts(
|
||||
{
|
||||
organizationId,
|
||||
excludeGroupId: id,
|
||||
workspaceIds: resolvedWorkspaceIds,
|
||||
candidateUserIds: members.map((m) => m.userId),
|
||||
},
|
||||
tx
|
||||
)
|
||||
if (conflicts.length > 0) {
|
||||
scopeConflicts = conflicts
|
||||
throw new Error('SCOPE_CONFLICT')
|
||||
}
|
||||
|
||||
// With no explicit members the group governs all members of its
|
||||
// workspaces; reject when another all-members group already does.
|
||||
if (!effectiveIsDefault && members.length === 0) {
|
||||
const conflict = await findAllMembersWorkspaceConflict(
|
||||
{ organizationId, excludeGroupId: id, workspaceIds: resolvedWorkspaceIds },
|
||||
tx
|
||||
)
|
||||
if (conflict) {
|
||||
allMembersConflict = conflict
|
||||
throw new Error('ALL_MEMBERS_CONFLICT')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.isDefault === true) {
|
||||
// Demote the prior default to a non-default group (only the default may
|
||||
// be org-wide); it ends up with no workspaces (inert) until an admin
|
||||
// re-scopes it.
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.isDefault, true)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({
|
||||
...(updates.name !== undefined && { name: updates.name }),
|
||||
...(updates.description !== undefined && { description: updates.description }),
|
||||
...(updates.isDefault !== undefined && { isDefault: updates.isDefault }),
|
||||
config: newConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(permissionGroup.id, id))
|
||||
|
||||
if (scopeProvided) {
|
||||
await tx
|
||||
.delete(permissionGroupWorkspace)
|
||||
.where(eq(permissionGroupWorkspace.permissionGroupId, id))
|
||||
if (!effectiveIsDefault && resolvedWorkspaceIds.length > 0) {
|
||||
await tx.insert(permissionGroupWorkspace).values(
|
||||
resolvedWorkspaceIds.map((workspaceId) => ({
|
||||
id: generateId(),
|
||||
permissionGroupId: id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
createdAt: now,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(permissionGroup)
|
||||
.where(eq(permissionGroup.id, id))
|
||||
.limit(1)
|
||||
|
||||
const finalWorkspaceIds = updated.isDefault
|
||||
? []
|
||||
: (await getGroupWorkspaces(id)).map((ws) => ws.id)
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_UPDATED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: updated.name,
|
||||
description: `Updated permission group "${updated.name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
updatedFields: Object.keys(updates).filter(
|
||||
(k) => updates[k as keyof typeof updates] !== undefined
|
||||
),
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
...updated,
|
||||
config: parsePermissionGroupConfig(updated.config),
|
||||
workspaceIds: finalWorkspaceIds,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'SCOPE_CONFLICT') {
|
||||
return NextResponse.json(
|
||||
{ error: formatScopeConflictError(scopeConflicts) },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'ALL_MEMBERS_CONFLICT' &&
|
||||
allMembersConflict
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: formatAllMembersConflictError(allMembersConflict) },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Another group was concurrently set as the default. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
// Advisory lock wait exceeded (lock_timeout) — transient contention.
|
||||
if (getPostgresErrorCode(error) === '55P03') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This group is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
logger.error('Error updating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ id: string; groupId: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId, groupId: id } = await params
|
||||
|
||||
try {
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const group = await loadGroupInOrganization(id, organizationId)
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
await tx
|
||||
.delete(permissionGroupMember)
|
||||
.where(eq(permissionGroupMember.permissionGroupId, id))
|
||||
await tx.delete(permissionGroup).where(eq(permissionGroup.id, id))
|
||||
})
|
||||
|
||||
logger.info('Deleted permission group', {
|
||||
permissionGroupId: id,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_DELETED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: group.name,
|
||||
description: `Deleted permission group "${group.name}"`,
|
||||
metadata: { organizationId },
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
// Advisory lock wait exceeded (lock_timeout) — transient contention.
|
||||
if (getPostgresErrorCode(error) === '55P03') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This group is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
logger.error('Error deleting permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
permissionGroup,
|
||||
permissionGroupMember,
|
||||
permissionGroupWorkspace,
|
||||
user,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createPermissionGroupContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
PERMISSION_GROUP_CONSTRAINTS,
|
||||
type PermissionGroupConfig,
|
||||
parsePermissionGroupConfig,
|
||||
} from '@/lib/permission-groups/types'
|
||||
import {
|
||||
type AllMembersConflict,
|
||||
acquirePermissionGroupOrgLock,
|
||||
authorizeOrgAccessControl,
|
||||
findAllMembersWorkspaceConflict,
|
||||
findWorkspacesNotInOrganization,
|
||||
formatAllMembersConflictError,
|
||||
getWorkspacesForGroups,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('OrganizationPermissionGroups')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const groups = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
|
||||
.where(eq(permissionGroup.organizationId, organizationId))
|
||||
.orderBy(desc(permissionGroup.createdAt))
|
||||
|
||||
const groupIds = groups.map((group) => group.id)
|
||||
const memberCounts = groupIds.length
|
||||
? await db
|
||||
.select({
|
||||
permissionGroupId: permissionGroupMember.permissionGroupId,
|
||||
count: count(),
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
|
||||
.groupBy(permissionGroupMember.permissionGroupId)
|
||||
: []
|
||||
const countByGroupId = new Map(memberCounts.map((row) => [row.permissionGroupId, row.count]))
|
||||
const workspacesByGroupId = await getWorkspacesForGroups(groupIds)
|
||||
|
||||
const groupsWithCounts = groups.map((group) => ({
|
||||
...group,
|
||||
config: parsePermissionGroupConfig(group.config),
|
||||
memberCount: countByGroupId.get(group.id) ?? 0,
|
||||
workspaces: workspacesByGroupId.get(group.id) ?? [],
|
||||
}))
|
||||
|
||||
return NextResponse.json({ permissionGroups: groupsWithCounts })
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await context.params
|
||||
|
||||
// Populated inside the transaction when an all-members scope conflict is
|
||||
// detected, so the catch can format the 409 after the rollback.
|
||||
let allMembersConflict: AllMembersConflict | null = null
|
||||
|
||||
try {
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const parsed = await parseRequest(createPermissionGroupContract, req, context, {
|
||||
validationErrorResponse: (error) =>
|
||||
NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, description, config, isDefault } = parsed.data.body
|
||||
|
||||
// Only the organization default group is org-wide; every other group
|
||||
// targets specific workspaces. "Org-wide" is definitionally `isDefault`.
|
||||
const isDefaultGroup = isDefault === true
|
||||
const workspaceIds = isDefaultGroup
|
||||
? []
|
||||
: Array.from(new Set(parsed.data.body.workspaceIds ?? []))
|
||||
|
||||
if (!isDefaultGroup && workspaceIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one workspace when the group targets specific workspaces' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isDefaultGroup) {
|
||||
const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId)
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'One or more selected workspaces do not belong to this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const existingGroup = await db
|
||||
.select({ id: permissionGroup.id })
|
||||
.from(permissionGroup)
|
||||
.where(
|
||||
and(eq(permissionGroup.organizationId, organizationId), eq(permissionGroup.name, name))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingGroup.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const groupConfig: PermissionGroupConfig = {
|
||||
...DEFAULT_PERMISSION_GROUP_CONFIG,
|
||||
...config,
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const newGroup = {
|
||||
id: generateId(),
|
||||
organizationId,
|
||||
name,
|
||||
description: description || null,
|
||||
config: groupConfig,
|
||||
createdBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDefault: isDefault || false,
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
// A new non-default group has no members, so it governs all members of
|
||||
// its workspaces; reject when another all-members group already does.
|
||||
if (!isDefaultGroup) {
|
||||
const conflict = await findAllMembersWorkspaceConflict(
|
||||
{ organizationId, excludeGroupId: newGroup.id, workspaceIds },
|
||||
tx
|
||||
)
|
||||
if (conflict) {
|
||||
allMembersConflict = conflict
|
||||
throw new Error('ALL_MEMBERS_CONFLICT')
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
// Demote the prior default to a non-default group (only the default may
|
||||
// be org-wide); it ends up with no workspaces (inert) until an admin
|
||||
// re-scopes it.
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.isDefault, true)
|
||||
)
|
||||
)
|
||||
}
|
||||
await tx.insert(permissionGroup).values(newGroup)
|
||||
if (workspaceIds.length > 0) {
|
||||
await tx.insert(permissionGroupWorkspace).values(
|
||||
workspaceIds.map((workspaceId) => ({
|
||||
id: generateId(),
|
||||
permissionGroupId: newGroup.id,
|
||||
workspaceId,
|
||||
organizationId,
|
||||
createdAt: now,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Created permission group', {
|
||||
permissionGroupId: newGroup.id,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
workspaceCount: workspaceIds.length,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.PERMISSION_GROUP_CREATED,
|
||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
||||
resourceId: newGroup.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: name,
|
||||
description: `Created permission group "${name}"`,
|
||||
metadata: {
|
||||
organizationId,
|
||||
isDefault: isDefault || false,
|
||||
workspaceCount: workspaceIds.length,
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
return NextResponse.json({ permissionGroup: { ...newGroup, workspaceIds } }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'ALL_MEMBERS_CONFLICT' &&
|
||||
allMembersConflict
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: formatAllMembersConflictError(allMembersConflict) },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (getPostgresErrorCode(error) === '55P03') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This organization is being updated by another request. Please try again.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A permission group with this name already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationDefault) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Another group was concurrently set as the default. Please refresh and try again.',
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
logger.error('Error creating permission group', error)
|
||||
return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockIsOrganizationAdminOrOwner,
|
||||
mockIsOrganizationOnEnterprisePlan,
|
||||
mockConflictRows,
|
||||
mockAllMembersRows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsOrganizationAdminOrOwner: vi.fn<() => Promise<boolean>>(),
|
||||
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
|
||||
mockConflictRows: {
|
||||
value: [] as Array<{
|
||||
userId: string
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
otherGroupId: string
|
||||
otherGroupName: string
|
||||
}>,
|
||||
},
|
||||
mockAllMembersRows: {
|
||||
value: [] as Array<{
|
||||
conflictingGroupId: string
|
||||
conflictingGroupName: string
|
||||
workspaceName: string
|
||||
}>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing', () => ({
|
||||
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
chain.from = vi.fn(() => chain)
|
||||
chain.innerJoin = vi.fn(() => chain)
|
||||
chain.leftJoin = vi.fn(() => chain)
|
||||
chain.where = vi.fn(() => chain)
|
||||
chain.orderBy = vi.fn(() => chain)
|
||||
// findAllMembersWorkspaceConflict ends in `.limit(1)`; findScopeConflicts
|
||||
// awaits the builder directly after `.where()`.
|
||||
chain.limit = vi.fn(() => Promise.resolve(mockAllMembersRows.value))
|
||||
chain.then = (onFulfilled: (rows: unknown) => unknown) =>
|
||||
Promise.resolve(mockConflictRows.value).then(onFulfilled)
|
||||
return chain
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
permissionGroup: {},
|
||||
permissionGroupMember: {},
|
||||
permissionGroupWorkspace: {},
|
||||
user: {},
|
||||
workspace: {},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
asc: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
ne: vi.fn(),
|
||||
sql: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
authorizeOrgAccessControl,
|
||||
findAllMembersWorkspaceConflict,
|
||||
findScopeConflicts,
|
||||
} from './utils'
|
||||
|
||||
describe('authorizeOrgAccessControl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns a 403 when the user is not an organization admin/owner', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(false)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response).not.toBeNull()
|
||||
expect(response?.status).toBe(403)
|
||||
await expect(response?.json()).resolves.toEqual({ error: 'Admin permissions required' })
|
||||
// Entitlement is only checked after the admin gate passes.
|
||||
expect(mockIsOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a 403 when the organization is not on an enterprise plan', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response?.status).toBe(403)
|
||||
await expect(response?.json()).resolves.toEqual({
|
||||
error: 'Access Control is an Enterprise feature',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the user is an admin and the org is entitled', async () => {
|
||||
mockIsOrganizationAdminOrOwner.mockResolvedValue(true)
|
||||
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
|
||||
|
||||
const response = await authorizeOrgAccessControl('user-1', 'org-1')
|
||||
|
||||
expect(response).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findScopeConflicts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConflictRows.value = []
|
||||
})
|
||||
|
||||
const baseParams = {
|
||||
organizationId: 'org-1',
|
||||
excludeGroupId: 'group-1',
|
||||
workspaceIds: ['ws-1'],
|
||||
candidateUserIds: ['user-1'],
|
||||
}
|
||||
|
||||
const conflictRow = (userId: string, otherGroupName = 'Marketing') => ({
|
||||
userId,
|
||||
userName: 'User One',
|
||||
userEmail: `${userId}@example.com`,
|
||||
otherGroupId: 'group-2',
|
||||
otherGroupName,
|
||||
})
|
||||
|
||||
it('returns no conflicts when there are no candidate users', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1')]
|
||||
|
||||
const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] })
|
||||
|
||||
expect(conflicts).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no conflicts when there are no target workspaces', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1')]
|
||||
|
||||
const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] })
|
||||
|
||||
expect(conflicts).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a candidate already in another group that shares a workspace', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1')]
|
||||
|
||||
const conflicts = await findScopeConflicts(baseParams)
|
||||
|
||||
expect(conflicts.map((c) => c.userId)).toEqual(['user-1'])
|
||||
expect(conflicts[0].conflictingGroupName).toBe('Marketing')
|
||||
})
|
||||
|
||||
it('returns at most one conflict per user', async () => {
|
||||
mockConflictRows.value = [conflictRow('user-1', 'Marketing'), conflictRow('user-1', 'Sales')]
|
||||
|
||||
const conflicts = await findScopeConflicts(baseParams)
|
||||
|
||||
expect(conflicts).toHaveLength(1)
|
||||
expect(conflicts[0].conflictingGroupName).toBe('Marketing')
|
||||
})
|
||||
|
||||
it('returns no conflicts when the query finds no overlapping memberships', async () => {
|
||||
mockConflictRows.value = []
|
||||
|
||||
const conflicts = await findScopeConflicts(baseParams)
|
||||
|
||||
expect(conflicts).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findAllMembersWorkspaceConflict', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAllMembersRows.value = []
|
||||
})
|
||||
|
||||
const baseParams = {
|
||||
organizationId: 'org-1',
|
||||
excludeGroupId: 'group-1',
|
||||
workspaceIds: ['ws-1', 'ws-2'],
|
||||
}
|
||||
|
||||
it('returns null when there are no target workspaces', async () => {
|
||||
mockAllMembersRows.value = [
|
||||
{ conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' },
|
||||
]
|
||||
|
||||
const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] })
|
||||
|
||||
expect(conflict).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the conflicting all-members group sharing a workspace', async () => {
|
||||
mockAllMembersRows.value = [
|
||||
{ conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' },
|
||||
]
|
||||
|
||||
const conflict = await findAllMembersWorkspaceConflict(baseParams)
|
||||
|
||||
expect(conflict).toEqual({
|
||||
conflictingGroupId: 'group-2',
|
||||
conflictingGroupName: 'Marketing',
|
||||
workspaceName: 'Acme',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when no other all-members group targets the workspaces', async () => {
|
||||
mockAllMembersRows.value = []
|
||||
|
||||
const conflict = await findAllMembersWorkspaceConflict(baseParams)
|
||||
|
||||
expect(conflict).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
permissionGroup,
|
||||
permissionGroupMember,
|
||||
permissionGroupWorkspace,
|
||||
user,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
/** A workspace reference (id + display name). */
|
||||
export interface WorkspaceRef {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize an organization-scoped access-control management request. The caller
|
||||
* must be an organization owner/admin and the organization must be entitled to
|
||||
* the Access Control (Permission Groups) enterprise feature. Returns a
|
||||
* `NextResponse` to short-circuit on failure, or `null` when authorized.
|
||||
*/
|
||||
export async function authorizeOrgAccessControl(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<NextResponse | null> {
|
||||
const isAdmin = await isOrganizationAdminOrOwner(userId, organizationId)
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const entitled = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
if (!entitled) {
|
||||
return NextResponse.json({ error: 'Access Control is an Enterprise feature' }, { status: 403 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Serialize all permission-group membership and scope writes for an organization
|
||||
* via a transaction-scoped Postgres advisory lock. Callers acquire it at the top
|
||||
* of the transaction that both checks (`findScopeConflicts`) and mutates, so a
|
||||
* concurrent member add or scope change can't commit in the check-to-write
|
||||
* window and leave a user governed by two groups on the same workspace.
|
||||
*
|
||||
* The invariant (one effective group per user per workspace) spans users and
|
||||
* groups in ways a unique constraint can't express, and these are low-frequency
|
||||
* admin writes, so a single org-scoped lock is simpler and more obviously
|
||||
* correct than fine-grained per-user/per-group locks with acquire-ordering.
|
||||
*
|
||||
* `pg_advisory_xact_lock` auto-releases at transaction end (safe on pooled
|
||||
* connections), and `lock_timeout` bounds the wait (raising SQLSTATE 55P03)
|
||||
* instead of hanging if a holder is stuck.
|
||||
*/
|
||||
export async function acquirePermissionGroupOrgLock(
|
||||
tx: DbOrTx,
|
||||
organizationId: string
|
||||
): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)`
|
||||
)
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`permission_group:${organizationId}`}, 0))`
|
||||
)
|
||||
}
|
||||
|
||||
/** Load a permission group only if it belongs to the given organization. */
|
||||
export async function loadGroupInOrganization(
|
||||
groupId: string,
|
||||
organizationId: string,
|
||||
executor: DbOrTx = db
|
||||
) {
|
||||
const [group] = await executor
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
name: permissionGroup.name,
|
||||
description: permissionGroup.description,
|
||||
config: permissionGroup.config,
|
||||
createdBy: permissionGroup.createdBy,
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
return group ?? null
|
||||
}
|
||||
|
||||
/** The workspaces ({id, name}) a specific-scope group targets. */
|
||||
export async function getGroupWorkspaces(
|
||||
groupId: string,
|
||||
executor: DbOrTx = db
|
||||
): Promise<WorkspaceRef[]> {
|
||||
return executor
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(permissionGroupWorkspace)
|
||||
.innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
|
||||
.where(eq(permissionGroupWorkspace.permissionGroupId, groupId))
|
||||
.orderBy(asc(workspace.name))
|
||||
}
|
||||
|
||||
/** Batched map of `groupId -> targeted workspaces` for a list of groups. */
|
||||
export async function getWorkspacesForGroups(
|
||||
groupIds: string[]
|
||||
): Promise<Map<string, WorkspaceRef[]>> {
|
||||
const byGroup = new Map<string, WorkspaceRef[]>()
|
||||
if (groupIds.length === 0) return byGroup
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
groupId: permissionGroupWorkspace.permissionGroupId,
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
})
|
||||
.from(permissionGroupWorkspace)
|
||||
.innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
|
||||
.where(inArray(permissionGroupWorkspace.permissionGroupId, groupIds))
|
||||
.orderBy(asc(workspace.name))
|
||||
|
||||
for (const row of rows) {
|
||||
const list = byGroup.get(row.groupId) ?? []
|
||||
list.push({ id: row.id, name: row.name })
|
||||
byGroup.set(row.groupId, list)
|
||||
}
|
||||
return byGroup
|
||||
}
|
||||
|
||||
/** Returns the subset of `workspaceIds` that do NOT belong to the organization. */
|
||||
export async function findWorkspacesNotInOrganization(
|
||||
workspaceIds: string[],
|
||||
organizationId: string
|
||||
): Promise<string[]> {
|
||||
if (workspaceIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(inArray(workspace.id, workspaceIds), eq(workspace.organizationId, organizationId)))
|
||||
const valid = new Set(rows.map((row) => row.id))
|
||||
return workspaceIds.filter((id) => !valid.has(id))
|
||||
}
|
||||
|
||||
/** List an organization's workspaces ({id, name}), ordered by name. */
|
||||
export async function listOrganizationWorkspaces(organizationId: string): Promise<WorkspaceRef[]> {
|
||||
return db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.organizationId, organizationId))
|
||||
.orderBy(asc(workspace.name))
|
||||
}
|
||||
|
||||
/** A member whose other group membership would conflict with a candidate scope. */
|
||||
export interface ScopeConflict {
|
||||
userId: string
|
||||
userName: string | null
|
||||
userEmail: string | null
|
||||
/** The group the member already belongs to that causes the conflict. */
|
||||
conflictingGroupId: string
|
||||
conflictingGroupName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `candidateUserIds` would be governed by two groups on the same
|
||||
* workspace: each is already an explicit member of another non-default group
|
||||
* that shares one of `workspaceIds`. The candidate group (`excludeGroupId`) and
|
||||
* the org default group are ignored — the default never governs through
|
||||
* membership. Returns at most one conflict per user.
|
||||
*/
|
||||
export async function findScopeConflicts(
|
||||
params: {
|
||||
organizationId: string
|
||||
excludeGroupId: string
|
||||
workspaceIds: string[]
|
||||
candidateUserIds: string[]
|
||||
},
|
||||
executor: DbOrTx = db
|
||||
): Promise<ScopeConflict[]> {
|
||||
const { organizationId, excludeGroupId, workspaceIds, candidateUserIds } = params
|
||||
if (candidateUserIds.length === 0 || workspaceIds.length === 0) return []
|
||||
|
||||
const rows = await executor
|
||||
.select({
|
||||
userId: permissionGroupMember.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
otherGroupId: permissionGroup.id,
|
||||
otherGroupName: permissionGroup.name,
|
||||
})
|
||||
.from(permissionGroupMember)
|
||||
.innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id))
|
||||
.innerJoin(
|
||||
permissionGroupWorkspace,
|
||||
eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id)
|
||||
)
|
||||
.leftJoin(user, eq(permissionGroupMember.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroupMember.organizationId, organizationId),
|
||||
inArray(permissionGroupMember.userId, candidateUserIds),
|
||||
ne(permissionGroupMember.permissionGroupId, excludeGroupId),
|
||||
eq(permissionGroup.isDefault, false),
|
||||
inArray(permissionGroupWorkspace.workspaceId, workspaceIds)
|
||||
)
|
||||
)
|
||||
|
||||
const conflictByUser = new Map<string, ScopeConflict>()
|
||||
for (const row of rows) {
|
||||
if (conflictByUser.has(row.userId)) continue
|
||||
conflictByUser.set(row.userId, {
|
||||
userId: row.userId,
|
||||
userName: row.userName,
|
||||
userEmail: row.userEmail,
|
||||
conflictingGroupId: row.otherGroupId,
|
||||
conflictingGroupName: row.otherGroupName,
|
||||
})
|
||||
}
|
||||
return Array.from(conflictByUser.values())
|
||||
}
|
||||
|
||||
/** An existing all-members group that already governs everyone in a shared workspace. */
|
||||
export interface AllMembersConflict {
|
||||
conflictingGroupId: string
|
||||
conflictingGroupName: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* For a group that will govern *all members* of `workspaceIds` (a non-default
|
||||
* group with no explicit members), return the first other non-default
|
||||
* all-members group already targeting one of those workspaces, or `null`. Two
|
||||
* all-members groups on one workspace would both claim everyone there, so this
|
||||
* is rejected at assignment time. The candidate group (`excludeGroupId`) is
|
||||
* ignored.
|
||||
*/
|
||||
export async function findAllMembersWorkspaceConflict(
|
||||
params: { organizationId: string; excludeGroupId: string; workspaceIds: string[] },
|
||||
executor: DbOrTx = db
|
||||
): Promise<AllMembersConflict | null> {
|
||||
const { organizationId, excludeGroupId, workspaceIds } = params
|
||||
if (workspaceIds.length === 0) return null
|
||||
|
||||
const [row] = await executor
|
||||
.select({
|
||||
conflictingGroupId: permissionGroup.id,
|
||||
conflictingGroupName: permissionGroup.name,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.innerJoin(
|
||||
permissionGroupWorkspace,
|
||||
eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id)
|
||||
)
|
||||
.innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
eq(permissionGroup.isDefault, false),
|
||||
ne(permissionGroup.id, excludeGroupId),
|
||||
inArray(permissionGroupWorkspace.workspaceId, workspaceIds),
|
||||
sql`not exists (
|
||||
select 1 from ${permissionGroupMember}
|
||||
where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id}
|
||||
)`
|
||||
)
|
||||
)
|
||||
.orderBy(asc(workspace.name))
|
||||
.limit(1)
|
||||
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable 409 message for a scope/membership conflict, naming the member
|
||||
* and the group they already belong to that overlaps the requested workspaces.
|
||||
*/
|
||||
export function formatScopeConflictError(conflicts: ScopeConflict[]): string {
|
||||
const [first] = conflicts
|
||||
if (!first) {
|
||||
return 'A member would be governed by two groups for the same workspace. Resolve their group memberships first.'
|
||||
}
|
||||
const who = first.userName || first.userEmail || 'A member'
|
||||
if (conflicts.length === 1) {
|
||||
return `${who} is already in the group "${first.conflictingGroupName}", which targets one of these workspaces. Remove them from one group first.`
|
||||
}
|
||||
const others = conflicts.length - 1
|
||||
return `${who} and ${others} other member${others === 1 ? '' : 's'} already belong to groups that target these workspaces (e.g. "${first.conflictingGroupName}"). Resolve their group memberships first.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable 409 message when another group already governs everyone in a
|
||||
* workspace this group would also apply to all members of.
|
||||
*/
|
||||
export function formatAllMembersConflictError(conflict: AllMembersConflict): string {
|
||||
return `The group "${conflict.conflictingGroupName}" already applies to everyone in "${conflict.workspaceName}". Two groups can't both govern all members of the same workspace — add members to one of them, or remove that workspace from one group first.`
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
member,
|
||||
permissions,
|
||||
user,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { organizationParamsSchema } from '@/lib/api/contracts/organization'
|
||||
import { getValidationErrorMessage } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core'
|
||||
|
||||
const logger = createLogger('OrganizationRosterAPI')
|
||||
|
||||
interface RosterWorkspaceAccess {
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const paramsResult = organizationParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id: organizationId } = paramsResult.data
|
||||
|
||||
const [callerMembership] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!callerMembership) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (callerMembership.role !== 'owner' && callerMembership.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Organization admin access required' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await expireStalePendingInvitationsForOrganization(organizationId)
|
||||
|
||||
const orgWorkspaces = await db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.organizationId, organizationId), isNull(workspace.archivedAt)))
|
||||
|
||||
const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id)
|
||||
const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name]))
|
||||
|
||||
const memberRows = await db
|
||||
.select({
|
||||
memberId: member.id,
|
||||
userId: member.userId,
|
||||
role: member.role,
|
||||
createdAt: member.createdAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
const memberUserIds = memberRows.map((row) => row.userId)
|
||||
|
||||
const memberPermissions =
|
||||
memberUserIds.length > 0 && orgWorkspaceIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
userId: permissions.userId,
|
||||
workspaceId: permissions.entityId,
|
||||
permission: permissions.permissionType,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
inArray(permissions.userId, memberUserIds),
|
||||
inArray(permissions.entityId, orgWorkspaceIds)
|
||||
)
|
||||
)
|
||||
: []
|
||||
|
||||
const permissionsByUser = new Map<string, RosterWorkspaceAccess[]>()
|
||||
for (const row of memberPermissions) {
|
||||
const list = permissionsByUser.get(row.userId) ?? []
|
||||
list.push({
|
||||
workspaceId: row.workspaceId,
|
||||
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
|
||||
permission: row.permission,
|
||||
})
|
||||
permissionsByUser.set(row.userId, list)
|
||||
}
|
||||
|
||||
const members = memberRows.map((row) => {
|
||||
const isOrgAdmin = isOrgAdminRole(row.role)
|
||||
return {
|
||||
memberId: row.memberId,
|
||||
userId: row.userId,
|
||||
role: row.role,
|
||||
createdAt: row.createdAt,
|
||||
name: row.userName,
|
||||
email: row.userEmail,
|
||||
image: row.userImage,
|
||||
workspaces: isOrgAdmin
|
||||
? orgWorkspaces.map((ws) => ({
|
||||
workspaceId: ws.id,
|
||||
workspaceName: ws.name,
|
||||
permission: 'admin' as const,
|
||||
}))
|
||||
: (permissionsByUser.get(row.userId) ?? []),
|
||||
}
|
||||
})
|
||||
|
||||
const externalPermissionRows =
|
||||
orgWorkspaceIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
workspaceId: permissions.entityId,
|
||||
permission: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.leftJoin(
|
||||
member,
|
||||
and(eq(member.userId, user.id), eq(member.organizationId, organizationId))
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
inArray(permissions.entityId, orgWorkspaceIds),
|
||||
isNull(member.id)
|
||||
)
|
||||
)
|
||||
: []
|
||||
|
||||
const externalMembersByUser = new Map<
|
||||
string,
|
||||
{
|
||||
memberId: string
|
||||
userId: string
|
||||
role: 'external'
|
||||
createdAt: Date
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
workspaces: RosterWorkspaceAccess[]
|
||||
}
|
||||
>()
|
||||
|
||||
for (const row of externalPermissionRows) {
|
||||
const existing = externalMembersByUser.get(row.userId)
|
||||
const workspaceAccess: RosterWorkspaceAccess = {
|
||||
workspaceId: row.workspaceId,
|
||||
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
|
||||
permission: row.permission,
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.workspaces.push(workspaceAccess)
|
||||
if (row.createdAt < existing.createdAt) existing.createdAt = row.createdAt
|
||||
continue
|
||||
}
|
||||
|
||||
externalMembersByUser.set(row.userId, {
|
||||
memberId: `external-${row.userId}`,
|
||||
userId: row.userId,
|
||||
role: 'external',
|
||||
createdAt: row.createdAt,
|
||||
name: row.userName,
|
||||
email: row.userEmail,
|
||||
image: row.userImage,
|
||||
workspaces: [workspaceAccess],
|
||||
})
|
||||
}
|
||||
|
||||
const rosterMembers = [...members, ...externalMembersByUser.values()]
|
||||
|
||||
const pendingInvitationRows = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
kind: invitation.kind,
|
||||
membershipIntent: invitation.membershipIntent,
|
||||
createdAt: invitation.createdAt,
|
||||
expiresAt: invitation.expiresAt,
|
||||
inviteeName: user.name,
|
||||
inviteeImage: user.image,
|
||||
})
|
||||
.from(invitation)
|
||||
.leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`)
|
||||
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
|
||||
|
||||
const pendingInvitationIds = pendingInvitationRows.map((row) => row.id)
|
||||
const pendingGrants =
|
||||
pendingInvitationIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
invitationId: invitationWorkspaceGrant.invitationId,
|
||||
workspaceId: invitationWorkspaceGrant.workspaceId,
|
||||
permission: invitationWorkspaceGrant.permission,
|
||||
})
|
||||
.from(invitationWorkspaceGrant)
|
||||
.where(inArray(invitationWorkspaceGrant.invitationId, pendingInvitationIds))
|
||||
: []
|
||||
|
||||
const grantsByInvitation = new Map<string, RosterWorkspaceAccess[]>()
|
||||
for (const row of pendingGrants) {
|
||||
const list = grantsByInvitation.get(row.invitationId) ?? []
|
||||
list.push({
|
||||
workspaceId: row.workspaceId,
|
||||
workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace',
|
||||
permission: row.permission,
|
||||
})
|
||||
grantsByInvitation.set(row.invitationId, list)
|
||||
}
|
||||
|
||||
const pendingInvitations = pendingInvitationRows.map((row) => ({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.membershipIntent === 'external' ? 'external' : row.role,
|
||||
kind: row.kind,
|
||||
membershipIntent: row.membershipIntent,
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
inviteeName: row.inviteeName,
|
||||
inviteeImage: row.inviteeImage,
|
||||
workspaces: grantsByInvitation.get(row.id) ?? [],
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
members: rosterMembers,
|
||||
pendingInvitations,
|
||||
workspaces: orgWorkspaces,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch organization roster', { error })
|
||||
return NextResponse.json({ error: 'Failed to fetch organization roster' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateOrganizationContract } from '@/lib/api/contracts/organization'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
getOrganizationSeatAnalytics,
|
||||
getOrganizationSeatInfo,
|
||||
} from '@/lib/billing/validation/seat-management'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('OrganizationAPI')
|
||||
|
||||
type OrganizationDetailsResponse = {
|
||||
success: true
|
||||
data: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string | null
|
||||
logo: string | null
|
||||
metadata: unknown
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
seats?: NonNullable<Awaited<ReturnType<typeof getOrganizationSeatInfo>>>
|
||||
seatAnalytics?: NonNullable<Awaited<ReturnType<typeof getOrganizationSeatAnalytics>>>
|
||||
}
|
||||
userRole: string
|
||||
hasAdminAccess: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]
|
||||
* Get organization details including settings and seat information
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
const url = new URL(request.url)
|
||||
const includeSeats = url.searchParams.get('include') === 'seats'
|
||||
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const organizationEntry = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (organizationEntry.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const userRole = memberEntry[0].role
|
||||
const hasAdminAccess = isOrgAdminRole(userRole)
|
||||
|
||||
const response: OrganizationDetailsResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
id: organizationEntry[0].id,
|
||||
name: organizationEntry[0].name,
|
||||
slug: organizationEntry[0].slug,
|
||||
logo: organizationEntry[0].logo,
|
||||
metadata: organizationEntry[0].metadata,
|
||||
createdAt: organizationEntry[0].createdAt,
|
||||
updatedAt: organizationEntry[0].updatedAt,
|
||||
},
|
||||
userRole,
|
||||
hasAdminAccess,
|
||||
}
|
||||
|
||||
if (includeSeats) {
|
||||
const seatInfo = await getOrganizationSeatInfo(organizationId)
|
||||
if (seatInfo) {
|
||||
response.data.seats = seatInfo
|
||||
}
|
||||
|
||||
if (hasAdminAccess) {
|
||||
const analytics = await getOrganizationSeatAnalytics(organizationId)
|
||||
if (analytics) {
|
||||
response.data.seatAnalytics = analytics
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization', {
|
||||
organizationId: (await params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]
|
||||
* Update organization settings (name, slug, logo)
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateOrganizationContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
const { name, slug, logo } = parsed.data.body
|
||||
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (memberEntry.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOrgAdminRole(memberEntry[0].role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (name !== undefined || slug !== undefined || logo !== undefined) {
|
||||
if (slug !== undefined) {
|
||||
const existingSlug = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(and(eq(organization.slug, slug), ne(organization.id, organizationId)))
|
||||
.limit(1)
|
||||
|
||||
if (existingSlug.length > 0) {
|
||||
return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: {
|
||||
updatedAt: Date
|
||||
name?: string
|
||||
slug?: string
|
||||
logo?: string | null
|
||||
} = { updatedAt: new Date() }
|
||||
if (name !== undefined) updateData.name = name
|
||||
if (slug !== undefined) updateData.slug = slug
|
||||
if (logo !== undefined) updateData.logo = logo
|
||||
|
||||
const updatedOrg = await db
|
||||
.update(organization)
|
||||
.set(updateData)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning()
|
||||
|
||||
if (updatedOrg.length === 0) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info('Organization settings updated', {
|
||||
organizationId,
|
||||
updatedBy: session.user.id,
|
||||
changes: { name, slug, logo },
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORGANIZATION_UPDATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: updatedOrg[0].name,
|
||||
description: `Updated organization settings`,
|
||||
metadata: { changes: { name, slug, logo } },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Organization updated successfully',
|
||||
data: {
|
||||
id: updatedOrg[0].id,
|
||||
name: updatedOrg[0].name,
|
||||
slug: updatedOrg[0].slug,
|
||||
logo: updatedOrg[0].logo,
|
||||
updatedAt: updatedOrg[0].updatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'No valid fields provided for update' }, { status: 400 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update organization', {
|
||||
organizationId: (await context.params).id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { transferOwnershipContract } from '@/lib/api/contracts/organization'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
|
||||
import {
|
||||
removeUserFromOrganization,
|
||||
transferOrganizationOwnership,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('TransferOwnershipAPI')
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(transferOwnershipContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
const { newOwnerUserId, alsoLeave } = parsed.data.body
|
||||
|
||||
if (newOwnerUserId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'New owner must differ from current owner' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [currentOwnerMember] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!currentOwnerMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You are not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (currentOwnerMember.role !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the current owner can transfer ownership' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [targetMember] = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId)))
|
||||
.limit(1)
|
||||
|
||||
if (!targetMember) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Target user is not a member of this organization' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const transferResult = await transferOrganizationOwnership({
|
||||
organizationId,
|
||||
currentOwnerUserId: session.user.id,
|
||||
newOwnerUserId,
|
||||
})
|
||||
|
||||
if (!transferResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: transferResult.error ?? 'Failed to transfer ownership' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: `Transferred ownership to ${targetMember.email}`,
|
||||
metadata: {
|
||||
targetUserId: newOwnerUserId,
|
||||
targetEmail: targetMember.email ?? undefined,
|
||||
targetName: targetMember.name ?? undefined,
|
||||
workspacesReassigned: transferResult.workspacesReassigned,
|
||||
billedAccountReassigned: transferResult.billedAccountReassigned,
|
||||
overageMigrated: transferResult.overageMigrated,
|
||||
billingBlockInherited: transferResult.billingBlockInherited,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
if (!alsoLeave) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
transferred: true,
|
||||
left: false,
|
||||
details: {
|
||||
workspacesReassigned: transferResult.workspacesReassigned,
|
||||
billedAccountReassigned: transferResult.billedAccountReassigned,
|
||||
overageMigrated: transferResult.overageMigrated,
|
||||
billingBlockInherited: transferResult.billingBlockInherited,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const [selfMember] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!selfMember) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
transferred: true,
|
||||
left: true,
|
||||
details: {
|
||||
workspacesReassigned: transferResult.workspacesReassigned,
|
||||
billedAccountReassigned: transferResult.billedAccountReassigned,
|
||||
overageMigrated: transferResult.overageMigrated,
|
||||
billingBlockInherited: transferResult.billingBlockInherited,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const removeResult = await removeUserFromOrganization({
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
memberId: selfMember.id,
|
||||
})
|
||||
|
||||
if (!removeResult.success) {
|
||||
logger.error('Transfer succeeded but self-removal failed', {
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
error: removeResult.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
transferred: true,
|
||||
left: false,
|
||||
warning: removeResult.error ?? 'Failed to leave after transfer',
|
||||
},
|
||||
{ status: 207 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await setActiveOrganizationForCurrentSession(null)
|
||||
} catch (clearError) {
|
||||
logger.warn('Failed to clear active organization after transfer-and-leave', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error: clearError,
|
||||
})
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
action: AuditAction.ORG_MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
description: 'Left the organization after transferring ownership',
|
||||
metadata: {
|
||||
targetUserId: session.user.id,
|
||||
wasSelfRemoval: true,
|
||||
followedOwnershipTransfer: true,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
transferred: true,
|
||||
left: true,
|
||||
details: {
|
||||
workspacesReassigned: transferResult.workspacesReassigned,
|
||||
billedAccountReassigned: transferResult.billedAccountReassigned,
|
||||
overageMigrated: transferResult.overageMigrated,
|
||||
billingBlockInherited: transferResult.billingBlockInherited,
|
||||
billingActions: removeResult.billingActions,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to transfer organization ownership', {
|
||||
organizationId: (await context.params).id,
|
||||
error,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateOrganizationWhitelabelContract } from '@/lib/api/contracts/organization'
|
||||
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
|
||||
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('WhitelabelAPI')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/whitelabel
|
||||
* Returns the organization's whitelabel settings.
|
||||
* Accessible by any member of the organization.
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({ whitelabelSettings: organization.whitelabelSettings })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get whitelabel settings', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/organizations/[id]/whitelabel
|
||||
* Updates the organization's whitelabel settings.
|
||||
* Requires enterprise plan and owner/admin role.
|
||||
*/
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateOrganizationWhitelabelContract, request, context, {
|
||||
validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
const incoming = parsed.data.body
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Forbidden - Only organization owners and admins can update whitelabel settings',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)
|
||||
|
||||
if (!hasEnterprisePlan) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Whitelabeling is available on Enterprise plans only' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [currentOrg] = await db
|
||||
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!currentOrg) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
|
||||
|
||||
const merged: OrganizationWhitelabelSettings = { ...current }
|
||||
|
||||
for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
|
||||
const value = incoming[key]
|
||||
if (value === null) {
|
||||
delete merged[key as keyof OrganizationWhitelabelSettings]
|
||||
} else if (value !== undefined) {
|
||||
;(merged as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(organization)
|
||||
.set({ whitelabelSettings: merged, updatedAt: new Date() })
|
||||
.where(eq(organization.id, organizationId))
|
||||
.returning({ whitelabelSettings: organization.whitelabelSettings })
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORGANIZATION_UPDATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: currentOrg.name,
|
||||
description: 'Updated organization whitelabel settings',
|
||||
metadata: { changes: Object.keys(incoming) },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update whitelabel settings', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { listOrganizationWorkspacesContract } from '@/lib/api/contracts/permission-groups'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
authorizeOrgAccessControl,
|
||||
listOrganizationWorkspaces,
|
||||
} from '@/app/api/organizations/[id]/permission-groups/utils'
|
||||
|
||||
const logger = createLogger('OrganizationWorkspaces')
|
||||
|
||||
/**
|
||||
* GET /api/organizations/[id]/workspaces
|
||||
*
|
||||
* Lists the workspaces belonging to an organization, used to populate the
|
||||
* workspace multi-select when scoping a permission group. Gated to organization
|
||||
* owners/admins on an Enterprise-entitled organization (same gate as the
|
||||
* permission-group management routes).
|
||||
*/
|
||||
export const GET = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(listOrganizationWorkspacesContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
const denied = await authorizeOrgAccessControl(session.user.id, organizationId)
|
||||
if (denied) return denied
|
||||
|
||||
const workspaces = await listOrganizationWorkspaces(organizationId)
|
||||
|
||||
logger.info('Listed organization workspaces', {
|
||||
organizationId,
|
||||
count: workspaces.length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ workspaces })
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createSession, loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockDbState,
|
||||
mockGetSession,
|
||||
mockSetActiveOrganizationForCurrentSession,
|
||||
mockCreateOrganizationForTeamPlan,
|
||||
mockEnsureOrganizationForTeamSubscription,
|
||||
mockAttachOwnedWorkspacesToOrganization,
|
||||
WorkspaceOrganizationMembershipConflictError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDbState: {
|
||||
selectResults: [] as any[],
|
||||
},
|
||||
mockGetSession: vi.fn(),
|
||||
mockSetActiveOrganizationForCurrentSession: vi.fn().mockResolvedValue(undefined),
|
||||
mockCreateOrganizationForTeamPlan: vi.fn(),
|
||||
mockEnsureOrganizationForTeamSubscription: vi.fn(),
|
||||
mockAttachOwnedWorkspacesToOrganization: vi.fn().mockResolvedValue(undefined),
|
||||
WorkspaceOrganizationMembershipConflictError: class WorkspaceOrganizationMembershipConflictError extends Error {},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
const chain: any = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
|
||||
chain.then = vi
|
||||
.fn()
|
||||
.mockImplementation((callback: (rows: any[]) => any) =>
|
||||
Promise.resolve(callback(mockDbState.selectResults.shift() ?? []))
|
||||
)
|
||||
return chain
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
member: {
|
||||
organizationId: 'member.organizationId',
|
||||
role: 'member.role',
|
||||
userId: 'member.userId',
|
||||
},
|
||||
organization: {
|
||||
id: 'organization.id',
|
||||
name: 'organization.name',
|
||||
},
|
||||
subscription: {
|
||||
id: 'subscription.id',
|
||||
plan: 'subscription.plan',
|
||||
referenceId: 'subscription.referenceId',
|
||||
status: 'subscription.status',
|
||||
seats: 'subscription.seats',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
|
||||
inArray: vi.fn((field: unknown, value: unknown[]) => ({ field, value })),
|
||||
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/active-organization', () => ({
|
||||
setActiveOrganizationForCurrentSession: mockSetActiveOrganizationForCurrentSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organization', () => ({
|
||||
createOrganizationForTeamPlan: mockCreateOrganizationForTeamPlan,
|
||||
ensureOrganizationForTeamSubscription: mockEnsureOrganizationForTeamSubscription,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/organizations/create-organization', () => ({
|
||||
OrganizationSlugInvalidError: class OrganizationSlugInvalidError extends Error {},
|
||||
OrganizationSlugTakenError: class OrganizationSlugTakenError extends Error {},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/plan-helpers', () => ({
|
||||
isOrgPlan: (plan: string) => plan === 'team' || plan === 'enterprise',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/subscriptions/utils', () => ({
|
||||
ENTITLED_SUBSCRIPTION_STATUSES: ['active', 'trialing'],
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
|
||||
attachOwnedWorkspacesToOrganization: mockAttachOwnedWorkspacesToOrganization,
|
||||
WorkspaceOrganizationMembershipConflictError,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/organizations/route'
|
||||
|
||||
describe('POST /api/organizations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbState.selectResults = []
|
||||
})
|
||||
|
||||
it('recovers an owner org when the subscription was already moved onto the organization', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({
|
||||
userId: 'user-1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
})
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ organizationId: 'legacy-org-id', role: 'owner' }],
|
||||
[{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Recovered Org' }),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
organizationId: 'legacy-org-id',
|
||||
created: false,
|
||||
})
|
||||
expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({
|
||||
ownerUserId: 'user-1',
|
||||
organizationId: 'legacy-org-id',
|
||||
})
|
||||
expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled()
|
||||
expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled()
|
||||
expect(mockSetActiveOrganizationForCurrentSession).toHaveBeenCalledWith('legacy-org-id')
|
||||
expect(auditMock.recordAudit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers an owner org when the subscription is still linked to the user', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({
|
||||
userId: 'user-1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
})
|
||||
)
|
||||
mockEnsureOrganizationForTeamSubscription.mockResolvedValue({
|
||||
id: 'sub-1',
|
||||
plan: 'team',
|
||||
referenceId: 'legacy-org-id',
|
||||
status: 'active',
|
||||
seats: 5,
|
||||
})
|
||||
mockDbState.selectResults = [
|
||||
[{ organizationId: 'legacy-org-id', role: 'owner' }],
|
||||
[{ id: 'sub-1', plan: 'team', referenceId: 'user-1', status: 'active', seats: 5 }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Recovered Org' }),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
success: true,
|
||||
organizationId: 'legacy-org-id',
|
||||
created: false,
|
||||
})
|
||||
expect(mockEnsureOrganizationForTeamSubscription).toHaveBeenCalledWith({
|
||||
id: 'sub-1',
|
||||
plan: 'team',
|
||||
referenceId: 'user-1',
|
||||
status: 'active',
|
||||
seats: 5,
|
||||
})
|
||||
expect(mockAttachOwnedWorkspacesToOrganization).not.toHaveBeenCalled()
|
||||
expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still blocks users who are only members of another organization', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({
|
||||
userId: 'user-1',
|
||||
email: 'member@example.com',
|
||||
name: 'Member',
|
||||
})
|
||||
)
|
||||
mockDbState.selectResults = [[{ organizationId: 'org-1', role: 'member' }]]
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Blocked Org' }),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error:
|
||||
'You are already a member of an organization. Leave your current organization before creating a new one.',
|
||||
})
|
||||
expect(mockEnsureOrganizationForTeamSubscription).not.toHaveBeenCalled()
|
||||
expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled()
|
||||
expect(mockAttachOwnedWorkspacesToOrganization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a conflict when existing shared workspace members block organization attachment', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({
|
||||
userId: 'user-1',
|
||||
email: 'owner@example.com',
|
||||
name: 'Owner',
|
||||
})
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ organizationId: 'legacy-org-id', role: 'owner' }],
|
||||
[{ id: 'sub-1', plan: 'team', referenceId: 'legacy-org-id', status: 'active', seats: 5 }],
|
||||
]
|
||||
mockAttachOwnedWorkspacesToOrganization.mockRejectedValueOnce(
|
||||
new WorkspaceOrganizationMembershipConflictError([
|
||||
{ userId: 'user-2', organizationId: 'org-2' },
|
||||
])
|
||||
)
|
||||
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Recovered Org' }),
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
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.',
|
||||
})
|
||||
expect(mockSetActiveOrganizationForCurrentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user