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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,209 @@
/**
* Admin Access Control (Permission Groups) API
*
* GET /api/v1/admin/access-control
* List all permission groups with optional filtering.
*
* Query Parameters:
* - organizationId?: string - Filter by organization ID
*
* Response: { data: AdminPermissionGroup[], pagination: PaginationMeta }
*
* DELETE /api/v1/admin/access-control
* Delete permission groups scoped to an organization.
* Used when an enterprise plan churns to clean up access control data.
*
* Query Parameters:
* - organizationId: string - Delete all permission groups for this organization
* - reason?: string - Reason recorded in audit log (default: "Enterprise plan churn cleanup")
*
* Response: { success: true, deletedCount: number, membersRemoved: number }
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq, inArray, sql } from 'drizzle-orm'
import {
type AdminV1PermissionGroup,
adminV1DeleteAccessControlContract,
adminV1ListAccessControlContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminAccessControlAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1ListAccessControlContract,
request,
{},
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
const { organizationId } = parsed.data.query
try {
const baseQuery = db
.select({
id: permissionGroup.id,
organizationId: permissionGroup.organizationId,
organizationName: organization.name,
name: permissionGroup.name,
description: permissionGroup.description,
isDefault: permissionGroup.isDefault,
createdAt: permissionGroup.createdAt,
createdByUserId: permissionGroup.createdBy,
createdByEmail: user.email,
})
.from(permissionGroup)
.leftJoin(organization, eq(permissionGroup.organizationId, organization.id))
.leftJoin(user, eq(permissionGroup.createdBy, user.id))
const groups = organizationId
? await baseQuery.where(eq(permissionGroup.organizationId, organizationId))
: await baseQuery
const groupsWithCounts = await Promise.all(
groups.map(async (group) => {
const [memberCount] = await db
.select({ count: count() })
.from(permissionGroupMember)
.where(eq(permissionGroupMember.permissionGroupId, group.id))
return {
id: group.id,
organizationId: group.organizationId,
organizationName: group.organizationName,
name: group.name,
description: group.description,
isDefault: group.isDefault,
memberCount: memberCount?.count ?? 0,
createdAt: group.createdAt.toISOString(),
createdByUserId: group.createdByUserId,
createdByEmail: group.createdByEmail,
} as AdminV1PermissionGroup
})
)
logger.info('Admin API: Listed permission groups', {
organizationId,
count: groupsWithCounts.length,
})
return singleResponse({
data: groupsWithCounts,
pagination: {
total: groupsWithCounts.length,
limit: groupsWithCounts.length,
offset: 0,
hasMore: false,
},
})
} catch (error) {
logger.error('Admin API: Failed to list permission groups', {
error,
organizationId,
})
return internalErrorResponse('Failed to list permission groups')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1DeleteAccessControlContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
}
)
if (!parsed.success) return parsed.response
const { organizationId, reason: rawReason } = parsed.data.query
// The contract's refine guarantees this at the boundary; this explicit guard
// narrows the type (avoiding a non-null assertion) and stays correct even if
// the contract changes.
if (!organizationId) {
return badRequestResponse('organizationId is required')
}
const reason = rawReason || 'Enterprise plan churn cleanup'
try {
const existingGroups = await db
.select({
id: permissionGroup.id,
name: permissionGroup.name,
})
.from(permissionGroup)
.where(eq(permissionGroup.organizationId, organizationId))
if (existingGroups.length === 0) {
logger.info('Admin API: No permission groups to delete', { organizationId })
return singleResponse({
success: true,
deletedCount: 0,
membersRemoved: 0,
message: 'No permission groups found for the given scope',
})
}
const groupIds = existingGroups.map((g) => g.id)
const [memberCountResult] = await db
.select({ count: sql<number>`count(*)` })
.from(permissionGroupMember)
.where(inArray(permissionGroupMember.permissionGroupId, groupIds))
const membersToRemove = Number(memberCountResult?.count ?? 0)
await db.delete(permissionGroup).where(inArray(permissionGroup.id, groupIds))
for (const group of existingGroups) {
recordAudit({
actorId: 'admin-api',
action: AuditAction.PERMISSION_GROUP_DELETED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: group.id,
resourceName: group.name,
description: `Admin API deleted permission group "${group.name}"`,
metadata: { reason, organizationId },
request,
})
}
logger.info('Admin API: Deleted permission groups', {
organizationId,
deletedCount: existingGroups.length,
membersRemoved: membersToRemove,
reason,
})
return singleResponse({
success: true,
deletedCount: existingGroups.length,
membersRemoved: membersToRemove,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to delete permission groups', {
error,
organizationId,
})
return internalErrorResponse('Failed to delete permission groups')
}
})
)
@@ -0,0 +1,55 @@
/**
* GET /api/v1/admin/audit-logs/[id]
*
* Get a single audit log entry by ID.
*
* Response: AdminSingleResponse<AdminAuditLog>
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { v1AdminGetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminAuditLog } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminAuditLogDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(v1AdminGetAuditLogContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
try {
const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1)
if (!log) {
return notFoundResponse('AuditLog')
}
logger.info(`Admin API: Retrieved audit log ${id}`)
return singleResponse(toAdminAuditLog(log))
} catch (error) {
logger.error('Admin API: Failed to get audit log', { error, id })
return internalErrorResponse('Failed to get audit log')
}
})
)
@@ -0,0 +1,95 @@
/**
* GET /api/v1/admin/audit-logs
*
* List all audit logs with pagination and filtering.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID
* - actorEmail: string (optional) - Filter by actor email
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
*
* Response: AdminListResponse<AdminAuditLog>
*/
import { dbReplica } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, desc } from 'drizzle-orm'
import { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminAuditLog,
createPaginationMeta,
parsePaginationParams,
toAdminAuditLog,
} from '@/app/api/v1/admin/types'
import { buildFilterConditions } from '@/app/api/v1/audit-logs/query'
const logger = createLogger('AdminAuditLogsAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
const parsed = await parseRequest(
v1AdminListAuditLogsContract,
request,
{},
{ validationErrorResponse: adminValidationErrorResponse }
)
if (!parsed.success) return parsed.response
try {
const query = parsed.data.query
const conditions = buildFilterConditions({
action: query.action,
resourceType: query.resourceType,
resourceId: query.resourceId,
workspaceId: query.workspaceId,
actorId: query.actorId,
actorEmail: query.actorEmail,
startDate: query.startDate,
endDate: query.endDate,
})
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [countResult, logs] = await Promise.all([
dbReplica.select({ total: count() }).from(auditLog).where(whereClause),
dbReplica
.select()
.from(auditLog)
.where(whereClause)
.orderBy(desc(auditLog.createdAt))
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminAuditLog[] = logs.map(toAdminAuditLog)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list audit logs', { error })
return internalErrorResponse('Failed to list audit logs')
}
})
)
+66
View File
@@ -0,0 +1,66 @@
/**
* Admin API Authentication
*
* Authenticates admin API requests using the ADMIN_API_KEY environment variable.
* Designed for self-hosted deployments where GitOps/scripted access is needed.
*
* Usage:
* curl -H "x-admin-key: your_admin_key" https://your-instance/api/v1/admin/...
*/
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
const logger = createLogger('AdminAuth')
interface AdminAuthSuccess {
authenticated: true
}
interface AdminAuthFailure {
authenticated: false
error: string
notConfigured?: boolean
}
export type AdminAuthResult = AdminAuthSuccess | AdminAuthFailure
/**
* Authenticate an admin API request.
*
* @param request - The incoming Next.js request
* @returns Authentication result with success status and optional error
*/
export function authenticateAdminRequest(request: NextRequest): AdminAuthResult {
const adminKey = env.ADMIN_API_KEY
if (!adminKey) {
logger.warn('ADMIN_API_KEY environment variable is not set')
return {
authenticated: false,
error: 'Admin API is not configured. Set ADMIN_API_KEY environment variable.',
notConfigured: true,
}
}
const providedKey = request.headers.get('x-admin-key')
if (!providedKey) {
return {
authenticated: false,
error: 'Admin API key required. Provide x-admin-key header.',
}
}
if (!safeCompare(providedKey, adminKey)) {
logger.warn('Invalid admin API key attempted', { keyPrefix: providedKey.slice(0, 8) })
return {
authenticated: false,
error: 'Invalid admin API key',
}
}
return { authenticated: true }
}
+249
View File
@@ -0,0 +1,249 @@
/**
* POST /api/v1/admin/credits
*
* Issue credits to a user by user ID or email.
*
* Body:
* - userId?: string - The user ID to issue credits to
* - email?: string - The user email to issue credits to (alternative to userId)
* - amount: number - The amount of credits to issue (in dollars)
* - reason?: string - Reason for issuing credits (for audit logging)
*
* Response: AdminSingleResponse<{
* success: true,
* entityType: 'user' | 'organization',
* entityId: string,
* amount: number,
* newCreditBalance: number,
* newUsageLimit: number,
* }>
*
* For Pro users: credits are added to user_stats.credit_balance
* For Team users: credits are added to organization.credit_balance
* Usage limits are updated accordingly to allow spending the credits.
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { adminV1IssueCreditsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { isPaid } from '@/lib/billing/plan-helpers'
import {
ENTITLED_SUBSCRIPTION_STATUSES,
getEffectiveSeats,
isOrgScopedSubscription,
} from '@/lib/billing/subscriptions/utils'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminCreditsAPI')
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1IssueCreditsContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
const { userId, email, amount, reason } = parsed.data.body
let resolvedUserId: string
let userEmail: string | null = null
if (userId) {
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
resolvedUserId = userData.id
userEmail = userData.email
} else {
if (!email) {
return badRequestResponse('Either userId or email is required')
}
const normalizedEmail = normalizeEmail(email)
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, normalizedEmail))
.limit(1)
if (!userData) {
return notFoundResponse('User with email')
}
resolvedUserId = userData.id
userEmail = userData.email
}
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
if (!userSubscription || !isPaid(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
}
let entityType: 'user' | 'organization'
let entityId: string
const plan = userSubscription.plan
let seats: number | null = null
// Route admin credits to the subscription's entity (org if org-scoped).
if (isOrgScopedSubscription(userSubscription, resolvedUserId)) {
entityType = 'organization'
entityId = userSubscription.referenceId
const [orgExists] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
if (!orgExists) {
return notFoundResponse('Organization')
}
const [subData] = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, entityId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
seats = getEffectiveSeats(subData)
} else {
entityType = 'user'
entityId = resolvedUserId
const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
if (!existingStats) {
await db.insert(userStats).values({
id: generateShortId(),
userId: entityId,
})
}
}
await addCredits(entityType, entityId, amount)
let newCreditBalance: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
} else {
const [stats] = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
}
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
let newUsageLimit: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
} else {
const [stats] = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
}
logger.info('Admin API: Issued credits', {
resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
reason: reason || 'No reason provided',
})
recordAudit({
actorId: 'admin-api',
action: AuditAction.CREDIT_ISSUED,
resourceType: AuditResourceType.BILLING,
resourceId: entityId,
description: `Admin API issued $${Number(amount).toFixed(2)} credits to ${entityType} ${entityId}`,
metadata: {
targetUserId: resolvedUserId,
...(entityType === 'organization'
? { targetOrgId: entityId, organizationId: entityId }
: {}),
entityType,
amount,
currency: 'usd',
reason: reason || null,
newCreditBalance,
},
request,
})
return singleResponse({
success: true,
userId: resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
})
} catch (error) {
logger.error('Admin API: Failed to issue credits', { error })
return internalErrorResponse('Failed to issue credits')
}
})
)
@@ -0,0 +1,251 @@
/**
* GET /api/v1/admin/folders/[id]/export
*
* Export a folder and all its contents (workflows + subfolders) as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip)
* - JSON: FolderExportFullPayload
*/
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type FolderExportPayload,
parseWorkflowVariables,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminFolderExportAPI')
interface RouteParams {
id: string
}
interface CollectedWorkflow {
id: string
folderId: string | null
}
/**
* Recursively collects all workflows within a folder and its subfolders.
*/
function collectWorkflowsInFolder(
folderId: string,
allWorkflows: Array<{ id: string; folderId: string | null }>,
allFolders: Array<{ id: string; parentId: string | null }>
): CollectedWorkflow[] {
const collected: CollectedWorkflow[] = []
for (const wf of allWorkflows) {
if (wf.folderId === folderId) {
collected.push({ id: wf.id, folderId: wf.folderId })
}
}
for (const folder of allFolders) {
if (folder.parentId === folderId) {
const childWorkflows = collectWorkflowsInFolder(folder.id, allWorkflows, allFolders)
collected.push(...childWorkflows)
}
}
return collected
}
/**
* Collects all subfolders recursively under a root folder.
* Returns folders with parentId adjusted so direct children of rootFolderId have parentId: null.
*/
function collectSubfolders(
rootFolderId: string,
allFolders: Array<{ id: string; name: string; parentId: string | null }>
): FolderExportPayload[] {
const subfolders: FolderExportPayload[] = []
function collect(parentId: string) {
for (const folder of allFolders) {
if (folder.parentId === parentId) {
subfolders.push({
id: folder.id,
name: folder.name,
parentId: folder.parentId === rootFolderId ? null : folder.parentId,
})
collect(folder.id)
}
}
}
collect(rootFolderId)
return subfolders
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ExportFolderContract, request, context)
if (!parsed.success) return parsed.response
const { id: folderId } = parsed.data.params
const { format } = parsed.data.query
try {
const [folderData] = await db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
workspaceId: workflowFolder.workspaceId,
})
.from(workflowFolder)
.where(eq(workflowFolder.id, folderId))
.limit(1)
if (!folderData) {
return notFoundResponse('Folder')
}
const allWorkflows = await db
.select({ id: workflow.id, folderId: workflow.folderId })
.from(workflow)
.where(eq(workflow.workspaceId, folderData.workspaceId))
const allFolders = await db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
parentId: workflowFolder.parentId,
})
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, folderData.workspaceId))
const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders)
const subfolders = collectSubfolders(folderId, allFolders)
const workflowExports: Array<{
workflow: {
id: string
name: string
description: string | null
folderId: string | null
}
state: WorkflowExportState
}> = []
for (const collectedWf of workflowsInFolder) {
try {
const [wfData] = await db
.select()
.from(workflow)
.where(eq(workflow.id, collectedWf.id))
.limit(1)
if (!wfData) {
logger.warn(`Skipping workflow ${collectedWf.id} - not found`)
continue
}
const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wfData.variables)
const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wfData.name,
description: wfData.description ?? undefined,
exportedAt: new Date().toISOString(),
},
variables,
}
workflowExports.push({
workflow: {
id: wfData.id,
name: wfData.name,
description: wfData.description,
folderId: remappedFolderId,
},
state,
})
} catch (error) {
logger.error(`Failed to load workflow ${collectedWf.id}:`, { error })
}
}
logger.info(
`Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders`
)
if (format === 'json') {
const exportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
folder: {
id: folderData.id,
name: folderData.name,
},
workflows: workflowExports,
folders: subfolders,
}
return singleResponse(exportPayload)
}
const zipWorkflows = workflowExports.map((wf) => ({
workflow: {
id: wf.workflow.id,
name: wf.workflow.name,
description: wf.workflow.description ?? undefined,
folderId: wf.workflow.folderId,
},
state: wf.state,
variables: wf.state.variables,
}))
const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = sanitizePathSegment(folderData.name)
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export folder', { error, folderId })
return internalErrorResponse('Failed to export folder')
}
})
)
+50
View File
@@ -0,0 +1,50 @@
import type { NextRequest, NextResponse } from 'next/server'
import { authenticateAdminRequest } from '@/app/api/v1/admin/auth'
import { notConfiguredResponse, unauthorizedResponse } from '@/app/api/v1/admin/responses'
export type AdminRouteHandler = (request: NextRequest) => Promise<NextResponse>
export type AdminRouteHandlerWithParams<TParams> = (
request: NextRequest,
context: { params: Promise<TParams> }
) => Promise<NextResponse>
/**
* Wrap a route handler with admin authentication.
* Returns early with an error response if authentication fails.
*/
export function withAdminAuth(handler: AdminRouteHandler): AdminRouteHandler {
return async (request: NextRequest) => {
const auth = authenticateAdminRequest(request)
if (!auth.authenticated) {
if (auth.notConfigured) {
return notConfiguredResponse()
}
return unauthorizedResponse(auth.error)
}
return handler(request)
}
}
/**
* Wrap a route handler with params with admin authentication.
* Returns early with an error response if authentication fails.
*/
export function withAdminAuthParams<TParams>(
handler: AdminRouteHandlerWithParams<TParams>
): AdminRouteHandlerWithParams<TParams> {
return async (request: NextRequest, context: { params: Promise<TParams> }) => {
const auth = authenticateAdminRequest(request)
if (!auth.authenticated) {
if (auth.notConfigured) {
return notConfiguredResponse()
}
return unauthorizedResponse(auth.error)
}
return handler(request, context)
}
}
@@ -0,0 +1,205 @@
/**
* GET /api/v1/admin/organizations/[id]/billing
*
* Get organization billing summary including usage, seats, and member data.
*
* Response: AdminSingleResponse<AdminOrganizationBillingSummary>
*
* PATCH /api/v1/admin/organizations/[id]/billing
*
* Update organization billing settings.
*
* Body:
* - orgUsageLimit?: number - New usage limit (null to clear)
*
* Response: AdminSingleResponse<{ success: true, orgUsageLimit: string | null }>
*/
import { db, dbReplica } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import {
adminV1GetOrganizationBillingContract,
adminV1UpdateOrganizationBillingContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminOrganizationBillingSummary } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationBillingAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetOrganizationBillingContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
try {
if (!isBillingEnabled) {
const [[orgData], [memberCount]] = await Promise.all([
db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1),
db
.select({ count: count() })
.from(member)
.where(eq(member.organizationId, organizationId)),
])
if (!orgData) {
return notFoundResponse('Organization')
}
const data: AdminOrganizationBillingSummary = {
organizationId: orgData.id,
organizationName: orgData.name,
subscriptionPlan: 'none',
subscriptionStatus: 'none',
totalSeats: Number.MAX_SAFE_INTEGER,
usedSeats: memberCount?.count || 0,
availableSeats: Number.MAX_SAFE_INTEGER,
totalCurrentUsage: 0,
totalUsageLimit: Number.MAX_SAFE_INTEGER,
minimumBillingAmount: 0,
averageUsagePerMember: 0,
usagePercentage: 0,
billingPeriodStart: null,
billingPeriodEnd: null,
membersOverLimit: 0,
membersNearLimit: 0,
}
logger.info(
`Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)`
)
return singleResponse(data)
}
const billingData = await getOrganizationBillingData(organizationId, dbReplica)
if (!billingData) {
return notFoundResponse('Organization or subscription')
}
const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length
const membersNearLimit = billingData.members.filter(
(m) => !m.isOverLimit && m.percentUsed >= 80
).length
const usagePercentage =
billingData.totalUsageLimit > 0
? Math.round((billingData.totalCurrentUsage / billingData.totalUsageLimit) * 10000) / 100
: 0
const data: AdminOrganizationBillingSummary = {
organizationId: billingData.organizationId,
organizationName: billingData.organizationName,
subscriptionPlan: billingData.subscriptionPlan,
subscriptionStatus: billingData.subscriptionStatus,
totalSeats: billingData.totalSeats,
usedSeats: billingData.usedSeats,
availableSeats: billingData.totalSeats - billingData.usedSeats,
totalCurrentUsage: billingData.totalCurrentUsage,
totalUsageLimit: billingData.totalUsageLimit,
minimumBillingAmount: billingData.minimumBillingAmount,
averageUsagePerMember: billingData.averageUsagePerMember,
usagePercentage,
billingPeriodStart: billingData.billingPeriodStart?.toISOString() ?? null,
billingPeriodEnd: billingData.billingPeriodEnd?.toISOString() ?? null,
membersOverLimit,
membersNearLimit,
}
logger.info(`Admin API: Retrieved billing summary for organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get organization billing', { error, organizationId })
return internalErrorResponse('Failed to get organization billing')
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const routeParams = await context.params
const { id: organizationId } = routeParams
try {
const parsed = await parseRequest(
adminV1UpdateOrganizationBillingContract,
request,
{ params: routeParams },
{
validationErrorResponse: adminValidationErrorResponse,
invalidJson: 'throw',
}
)
if (!parsed.success) return parsed.response
const [orgData] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const { orgUsageLimit } = parsed.data.body
if (orgUsageLimit !== undefined) {
let newLimit: string | null = null
if (orgUsageLimit === null) {
newLimit = null
} else {
newLimit = orgUsageLimit.toFixed(2)
}
await db
.update(organization)
.set({
orgUsageLimit: newLimit,
updatedAt: new Date(),
})
.where(eq(organization.id, organizationId))
logger.info(`Admin API: Updated usage limit for organization ${organizationId}`, {
newLimit,
})
return singleResponse({
success: true,
orgUsageLimit: newLimit,
})
}
return badRequestResponse('No valid fields to update')
} catch (error) {
logger.error('Admin API: Failed to update organization billing', { error, organizationId })
return internalErrorResponse('Failed to update organization billing')
}
})
)
@@ -0,0 +1,322 @@
/**
* GET /api/v1/admin/organizations/[id]/members/[memberId]
*
* Get member details.
*
* Response: AdminSingleResponse<AdminMemberDetail>
*
* PATCH /api/v1/admin/organizations/[id]/members/[memberId]
*
* Update member role.
*
* Body:
* - role: string - New role ('admin' | 'member')
*
* Response: AdminSingleResponse<AdminMember>
*
* DELETE /api/v1/admin/organizations/[id]/members/[memberId]
*
* Remove member from organization with full billing logic.
* Handles departed usage capture and Pro restoration like the regular flow.
*
* Query Parameters:
* - skipBillingLogic: boolean - Skip billing logic (default: false)
*
* Response: { success: true, memberId: string, billingActions: {...} }
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import {
adminV1GetOrganizationMemberContract,
adminV1RemoveOrganizationMemberContract,
adminV1UpdateOrganizationMemberContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
import {
removeUserFromOrganization,
WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR,
} from '@/lib/billing/organizations/membership'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminMember, AdminMemberDetail } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationMemberDetailAPI')
interface RouteParams {
id: string
memberId: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetOrganizationMemberContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId, memberId } = parsed.data.params
try {
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [memberData] = 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,
billingBlocked: userStats.billingBlocked,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!memberData) {
return notFoundResponse('Member')
}
// currentPeriodCost is only a baseline; add this member's attributed
// usage_log for the org's period so admin shows real current usage.
const ledgerByUser = await getOrgMemberLedgerByUser(organizationId)
const data: AdminMemberDetail = {
id: memberData.id,
userId: memberData.userId,
organizationId: memberData.organizationId,
role: memberData.role,
createdAt: memberData.createdAt.toISOString(),
userName: memberData.userName,
userEmail: memberData.userEmail,
currentPeriodCost: (
Number(memberData.currentPeriodCost ?? 0) + (ledgerByUser.get(memberData.userId) ?? 0)
).toString(),
currentUsageLimit: memberData.currentUsageLimit,
billingBlocked: memberData.billingBlocked ?? false,
}
logger.info(`Admin API: Retrieved member ${memberId} from organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get member', { error, organizationId, memberId })
return internalErrorResponse('Failed to get member')
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const routeParams = await context.params
const { id: organizationId, memberId } = routeParams
try {
const parsed = await parseRequest(
adminV1UpdateOrganizationMemberContract,
request,
{ params: routeParams },
{
validationErrorResponse: adminValidationErrorResponse,
invalidJson: 'throw',
}
)
if (!parsed.success) return parsed.response
const { role } = parsed.data.body
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [existingMember] = await db
.select({
id: member.id,
userId: member.userId,
role: member.role,
})
.from(member)
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!existingMember) {
return notFoundResponse('Member')
}
if (existingMember.role === 'owner') {
return badRequestResponse('Cannot change owner role')
}
const [updated] = await db
.update(member)
.set({ role })
.where(eq(member.id, memberId))
.returning()
const [userData] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, updated.userId))
.limit(1)
const data: AdminMember = {
id: updated.id,
userId: updated.userId,
organizationId: updated.organizationId,
role: updated.role,
createdAt: updated.createdAt.toISOString(),
userName: userData?.name ?? '',
userEmail: userData?.email ?? '',
}
logger.info(`Admin API: Updated member ${memberId} role to ${role}`, {
organizationId,
previousRole: existingMember.role,
})
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `Admin API changed organization member role to ${role}`,
metadata: {
memberId,
targetUserId: existingMember.userId,
previousRole: existingMember.role,
role,
},
request,
})
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to update member', { error, organizationId, memberId })
return internalErrorResponse('Failed to update member')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1RemoveOrganizationMemberContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId, memberId } = parsed.data.params
const skipBillingLogic = !isBillingEnabled || parsed.data.query.skipBillingLogic
try {
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [existingMember] = await db
.select({
id: member.id,
userId: member.userId,
role: member.role,
})
.from(member)
.where(and(eq(member.id, memberId), eq(member.organizationId, organizationId)))
.limit(1)
if (!existingMember) {
return notFoundResponse('Member')
}
const userId = existingMember.userId
const result = await removeUserFromOrganization({
userId,
organizationId,
memberId,
skipBillingLogic,
})
if (!result.success) {
if (result.error === 'Cannot remove organization owner') {
return badRequestResponse(result.error)
}
if (result.error === 'Member not found') {
return notFoundResponse('Member')
}
if (result.error === WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR) {
return badRequestResponse(result.error)
}
return internalErrorResponse(result.error || 'Failed to remove member')
}
logger.info(`Admin API: Removed member ${memberId} from organization ${organizationId}`, {
userId,
billingActions: result.billingActions,
})
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORG_MEMBER_REMOVED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: 'Admin API removed member from organization',
metadata: { memberId, targetUserId: userId },
request,
})
return singleResponse({
success: true,
memberId,
userId,
billingActions: {
usageCaptured: result.billingActions.usageCaptured,
proRestored: result.billingActions.proRestored,
usageRestored: result.billingActions.usageRestored,
skipBillingLogic,
},
})
} catch (error) {
logger.error('Admin API: Failed to remove member', { error, organizationId, memberId })
return internalErrorResponse('Failed to remove member')
}
})
)
@@ -0,0 +1,307 @@
/**
* GET /api/v1/admin/organizations/[id]/members
*
* List all members of an organization with their billing info.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminMemberDetail>
*
* POST /api/v1/admin/organizations/[id]/members
*
* Add a user to an organization with full billing logic.
* Validates seat availability before adding (uses same logic as invitation flow):
* - Team plans: checks seats column
* - Enterprise plans: checks metadata.seats
* Handles Pro usage snapshot and subscription cancellation like the invitation flow.
* If user is already a member, updates their role if different.
*
* Body:
* - userId: string - User ID to add
* - role: string - Role ('admin' | 'member')
*
* Response: AdminSingleResponse<AdminMember & {
* action: 'created' | 'updated' | 'already_member',
* billingActions: { proUsageSnapshotted, proCancelledAtPeriodEnd }
* }>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import {
adminV1AddOrganizationMemberContract,
adminV1ListOrganizationMembersContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getOrgMemberLedgerByUser } from '@/lib/billing/core/organization'
import { addUserToOrganization } from '@/lib/billing/organizations/membership'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminMember,
type AdminMemberDetail,
createPaginationMeta,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationMembersAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListOrganizationMembersContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const [orgData] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [countResult, membersData] = await Promise.all([
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
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,
billingBlocked: userStats.billingBlocked,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
.orderBy(member.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].count
// currentPeriodCost is only a baseline; add each member's attributed
// usage_log for the org's period so admin shows real current usage.
const usageByUser = await getOrgMemberLedgerByUser(organizationId)
const data: AdminMemberDetail[] = membersData.map((m) => ({
id: m.id,
userId: m.userId,
organizationId: m.organizationId,
role: m.role,
createdAt: m.createdAt.toISOString(),
userName: m.userName,
userEmail: m.userEmail,
currentPeriodCost: (
Number(m.currentPeriodCost ?? 0) + (usageByUser.get(m.userId) ?? 0)
).toString(),
currentUsageLimit: m.currentUsageLimit,
billingBlocked: m.billingBlocked ?? false,
}))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} members for organization ${organizationId}`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list organization members', { error, organizationId })
return internalErrorResponse('Failed to list organization members')
}
})
)
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1AddOrganizationMemberContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
try {
const { userId, role } = parsed.data.body
const [orgData] = await db
.select({ id: organization.id, name: organization.name })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [userData] = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [existingMember] = await db
.select({
id: member.id,
role: member.role,
createdAt: member.createdAt,
organizationId: member.organizationId,
})
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (existingMember) {
if (existingMember.organizationId === organizationId) {
if (existingMember.role === 'owner') {
return badRequestResponse(
'Cannot change the owner role via this endpoint. Use POST /api/v1/admin/organizations/[id]/transfer-ownership instead.'
)
}
if (existingMember.role !== role) {
await db.update(member).set({ role }).where(eq(member.id, existingMember.id))
logger.info(
`Admin API: Updated user ${userId} role in organization ${organizationId}`,
{
previousRole: existingMember.role,
newRole: role,
}
)
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `Admin API changed organization member role to ${role}`,
metadata: { targetUserId: userId, previousRole: existingMember.role, role },
request,
})
return singleResponse({
id: existingMember.id,
userId,
organizationId,
role,
createdAt: existingMember.createdAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
action: 'updated' as const,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
return singleResponse({
id: existingMember.id,
userId,
organizationId,
role: existingMember.role,
createdAt: existingMember.createdAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
action: 'already_member' as const,
billingActions: {
proUsageSnapshotted: false,
proCancelledAtPeriodEnd: false,
},
})
}
return badRequestResponse(
`User is already a member of another organization. Users can only belong to one organization at a time.`
)
}
const result = await addUserToOrganization({
userId,
organizationId,
role,
skipBillingLogic: !isBillingEnabled,
})
if (!result.success) {
return badRequestResponse(result.error || 'Failed to add member')
}
const data: AdminMember = {
id: result.memberId!,
userId,
organizationId,
role,
createdAt: new Date().toISOString(),
userName: userData.name,
userEmail: userData.email,
}
logger.info(`Admin API: Added user ${userId} to organization ${organizationId}`, {
role,
memberId: result.memberId,
billingActions: result.billingActions,
})
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORG_MEMBER_ADDED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: `Admin API added member to organization as ${role}`,
metadata: { targetUserId: userId, role, memberId: result.memberId },
request,
})
return singleResponse({
...data,
action: 'created' as const,
billingActions: {
proUsageSnapshotted: result.billingActions.proUsageSnapshotted,
proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd,
},
})
} catch (error) {
logger.error('Admin API: Failed to add organization member', { error, organizationId })
return internalErrorResponse('Failed to add organization member')
}
})
)
@@ -0,0 +1,193 @@
/**
* GET /api/v1/admin/organizations/[id]
*
* Get organization details including member count and subscription.
*
* Response: AdminSingleResponse<AdminOrganizationDetail>
*
* PATCH /api/v1/admin/organizations/[id]
*
* Update organization details.
*
* Body:
* - name?: string - Organization name
* - slug?: string - Organization slug
*
* Response: AdminSingleResponse<AdminOrganization>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, inArray } from 'drizzle-orm'
import {
adminV1GetOrganizationContract,
adminV1UpdateOrganizationContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import {
ensureOrganizationSlugAvailable,
OrganizationSlugInvalidError,
OrganizationSlugTakenError,
validateOrganizationSlugOrThrow,
} from '@/lib/billing/organizations/create-organization'
import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminOrganizationDetail,
toAdminOrganization,
toAdminSubscription,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetOrganizationContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
try {
const [orgData] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgData) {
return notFoundResponse('Organization')
}
const [memberCountResult, subscriptionData] = await Promise.all([
db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)),
db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, organizationId),
inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES)
)
)
.limit(1),
])
const data: AdminOrganizationDetail = {
...toAdminOrganization(orgData),
memberCount: memberCountResult[0].count,
subscription: subscriptionData[0] ? toAdminSubscription(subscriptionData[0]) : null,
}
logger.info(`Admin API: Retrieved organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get organization', { error, organizationId })
return internalErrorResponse('Failed to get organization')
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1UpdateOrganizationContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
try {
const [existing] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!existing) {
return notFoundResponse('Organization')
}
const updateData: Record<string, unknown> = {
updatedAt: new Date(),
}
const validatedBody = parsed.data.body
if (validatedBody.name !== undefined) {
updateData.name = validatedBody.name
}
if (validatedBody.slug !== undefined) {
const nextSlug = validatedBody.slug
validateOrganizationSlugOrThrow(nextSlug)
await ensureOrganizationSlugAvailable({
slug: nextSlug,
excludeOrganizationId: organizationId,
})
updateData.slug = nextSlug
}
if (Object.keys(updateData).length === 1) {
return badRequestResponse(
'No valid fields to update. Use /billing endpoint for orgUsageLimit.'
)
}
const [updated] = await db
.update(organization)
.set(updateData)
.where(eq(organization.id, organizationId))
.returning()
logger.info(`Admin API: Updated organization ${organizationId}`, {
fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
})
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
resourceName: updated.name,
description: `Admin API updated organization "${updated.name}"`,
metadata: { fields: Object.keys(updateData).filter((k) => k !== 'updatedAt') },
request,
})
return singleResponse(toAdminOrganization(updated))
} catch (error) {
if (error instanceof OrganizationSlugInvalidError) {
return badRequestResponse(
'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.'
)
}
if (error instanceof OrganizationSlugTakenError) {
return badRequestResponse('This slug is already taken')
}
logger.error('Admin API: Failed to update organization', { error, organizationId })
return internalErrorResponse('Failed to update organization')
}
})
)
@@ -0,0 +1,64 @@
/**
* GET /api/v1/admin/organizations/[id]/seats
*
* Get organization seat analytics including member activity.
*
* Response: AdminSingleResponse<AdminSeatAnalytics>
*/
import { createLogger } from '@sim/logger'
import { adminV1GetOrganizationSeatsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminSeatAnalytics } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationSeatsAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetOrganizationSeatsContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: organizationId } = parsed.data.params
try {
const analytics = await getOrganizationSeatAnalytics(organizationId)
if (!analytics) {
return notFoundResponse('Organization or subscription')
}
const data: AdminSeatAnalytics = {
organizationId: analytics.organizationId,
organizationName: analytics.organizationName,
currentSeats: analytics.currentSeats,
maxSeats: analytics.maxSeats,
availableSeats: analytics.availableSeats,
subscriptionPlan: analytics.subscriptionPlan,
canAddSeats: analytics.canAddSeats,
utilizationRate: analytics.utilizationRate,
}
logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get organization seats', { error, organizationId })
return internalErrorResponse('Failed to get organization seats')
}
})
)
@@ -0,0 +1,145 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { adminV1TransferOwnershipContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { transferOrganizationOwnership } from '@/lib/billing/organizations/membership'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminTransferOwnershipAPI')
interface RouteParams {
id: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const routeParams = await context.params
const { id: organizationId } = routeParams
try {
const parsed = await parseRequest(
adminV1TransferOwnershipContract,
request,
{ params: routeParams },
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: () => badRequestResponse('Invalid request body'),
}
)
if (!parsed.success) return parsed.response
const { newOwnerUserId, currentOwnerUserId: currentOwnerUserIdOverride } = parsed.data.body
const [orgRow] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
if (!orgRow) {
return notFoundResponse('Organization')
}
let currentOwnerUserId: string
if (currentOwnerUserIdOverride) {
currentOwnerUserId = currentOwnerUserIdOverride
} else {
const [ownerMembership] = await db
.select({ userId: member.userId })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
.limit(1)
if (!ownerMembership) {
return badRequestResponse(
'Organization has no owner; provide currentOwnerUserId explicitly to seed ownership'
)
}
currentOwnerUserId = ownerMembership.userId
}
if (currentOwnerUserId === newOwnerUserId) {
return badRequestResponse('New owner must differ from current owner')
}
const [newOwnerMember] = 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 (!newOwnerMember) {
return badRequestResponse('Target user is not a member of this organization')
}
const result = await transferOrganizationOwnership({
organizationId,
currentOwnerUserId,
newOwnerUserId,
})
if (!result.success) {
return internalErrorResponse(result.error ?? 'Failed to transfer ownership')
}
logger.info(`Admin API: Transferred ownership of organization ${organizationId}`, {
currentOwnerUserId,
newOwnerUserId,
workspacesReassigned: result.workspacesReassigned,
billedAccountReassigned: result.billedAccountReassigned,
overageMigrated: result.overageMigrated,
billingBlockInherited: result.billingBlockInherited,
})
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
description: 'Admin API transferred organization ownership',
metadata: {
currentOwnerUserId,
newOwnerUserId,
workspacesReassigned: result.workspacesReassigned,
billedAccountReassigned: result.billedAccountReassigned,
},
request,
})
return singleResponse({
organizationId,
currentOwnerUserId,
newOwnerUserId,
workspacesReassigned: result.workspacesReassigned,
billedAccountReassigned: result.billedAccountReassigned,
overageMigrated: result.overageMigrated,
billingBlockInherited: result.billingBlockInherited,
})
} catch (error) {
logger.error('Admin API: Failed to transfer organization ownership', {
organizationId,
error,
})
return internalErrorResponse('Failed to transfer ownership')
}
})
)
@@ -0,0 +1,202 @@
/**
* GET /api/v1/admin/organizations
*
* List all organizations with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminOrganization>
*
* POST /api/v1/admin/organizations
*
* Create a new organization.
*
* Body:
* - name: string - Organization name (required)
* - slug: string - Organization slug (optional, auto-generated from name if not provided)
* - ownerId: string - User ID of the organization owner (required)
*
* Response: AdminSingleResponse<AdminOrganization & { memberId: string }>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db, dbReplica } from '@sim/db'
import { member, organization, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import {
adminV1CreateOrganizationContract,
adminV1ListOrganizationsContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import {
createOrganizationWithOwner,
OrganizationSlugInvalidError,
OrganizationSlugTakenError,
} from '@/lib/billing/organizations/create-organization'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminOrganization,
createPaginationMeta,
toAdminOrganization,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminOrganizationsAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1ListOrganizationsContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
}
)
if (!parsed.success) return parsed.response
const { limit, offset } = parsed.data.query
try {
const [countResult, organizations] = await Promise.all([
dbReplica.select({ total: count() }).from(organization),
dbReplica
.select({
id: organization.id,
name: organization.name,
slug: organization.slug,
logo: organization.logo,
orgUsageLimit: organization.orgUsageLimit,
storageUsedBytes: organization.storageUsedBytes,
departedMemberUsage: organization.departedMemberUsage,
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,
})
.from(organization)
.orderBy(organization.name)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminOrganization[] = organizations.map(toAdminOrganization)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} organizations (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list organizations', { error })
return internalErrorResponse('Failed to list organizations')
}
})
)
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1CreateOrganizationContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
}
)
if (!parsed.success) return parsed.response
try {
const { name, ownerId, slug: requestedSlug } = parsed.data.body
const [ownerData] = await db
.select({ id: user.id, name: user.name })
.from(user)
.where(eq(user.id, ownerId))
.limit(1)
if (!ownerData) {
return notFoundResponse('Owner user')
}
const [existingMembership] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, ownerId))
.limit(1)
if (existingMembership) {
return badRequestResponse(
'User is already a member of another organization. Users can only belong to one organization at a time.'
)
}
const slug =
requestedSlug?.trim() ||
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const { organizationId, memberId } = await createOrganizationWithOwner({
ownerUserId: ownerId,
name,
slug,
})
const [createdOrg] = await db
.select()
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)
logger.info(`Admin API: Created organization ${organizationId}`, {
name,
slug,
ownerId,
memberId,
})
recordAudit({
workspaceId: null,
actorId: 'admin-api',
action: AuditAction.ORGANIZATION_CREATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
resourceName: name,
description: `Admin API created organization "${name}"`,
metadata: { slug, ownerId, memberId },
request,
})
return singleResponse({
...toAdminOrganization(createdOrg),
memberId,
})
} catch (error) {
if (error instanceof OrganizationSlugInvalidError) {
return badRequestResponse(
'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.'
)
}
if (error instanceof OrganizationSlugTakenError) {
return badRequestResponse('This slug is already taken')
}
logger.error('Admin API: Failed to create organization', { error })
return internalErrorResponse('Failed to create organization')
}
})
)
@@ -0,0 +1,77 @@
import { db } from '@sim/db'
import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { adminV1RequeueOutboxEventContract } from '@/lib/api/contracts/v1/admin'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
const logger = createLogger('AdminOutboxRequeueAPI')
export const dynamic = 'force-dynamic'
const invalidOutboxEventResponse = (message: string) =>
NextResponse.json({ success: false, error: message }, { status: 400 })
/**
* POST /api/v1/admin/outbox/[id]/requeue
*
* Move a dead-lettered outbox event back to `pending` so the worker
* will retry it. Resets `attempts`, `lastError`, and `availableAt` so
* the next poll picks it up. Only dead-lettered events can be
* requeued — completed/pending/processing rows are rejected to avoid
* operator errors.
*/
export const POST = withRouteHandler(
withAdminAuthParams<{ id: string }>(async (request, context) => {
const parsed = await parseRequest(adminV1RequeueOutboxEventContract, request, context, {
validationErrorResponse: (error) =>
invalidOutboxEventResponse(getValidationErrorMessage(error, 'Invalid event ID')),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
try {
const result = await db
.update(outboxEvent)
.set({
status: 'pending',
attempts: 0,
lastError: null,
availableAt: new Date(),
lockedAt: null,
processedAt: null,
})
.where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter')))
.returning({ id: outboxEvent.id, eventType: outboxEvent.eventType })
if (result.length === 0) {
return NextResponse.json(
{
success: false,
error:
'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.',
},
{ status: 404 }
)
}
logger.info('Requeued dead-lettered outbox event', {
eventId: result[0].id,
eventType: result[0].eventType,
})
return NextResponse.json({
success: true,
requeued: result[0],
})
} catch (error) {
logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message })
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
})
)
+83
View File
@@ -0,0 +1,83 @@
import { db } from '@sim/db'
import { outboxEvent } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { adminV1ListOutboxContract } from '@/lib/api/contracts/v1/admin'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
const logger = createLogger('AdminOutboxAPI')
export const dynamic = 'force-dynamic'
const invalidOutboxQueryResponse = (message: string) =>
NextResponse.json({ success: false, error: message }, { status: 400 })
/**
* GET /api/v1/admin/outbox?status=dead_letter&eventType=...&limit=100
*
* Inspect outbox events for operator triage. Primary use: list
* dead-lettered rows to reconcile Stripe state manually after a
* permanent handler failure (e.g. Stripe account frozen, subscription
* already canceled by another path, etc.).
*
* Filters:
* - `status`: 'pending' | 'processing' | 'completed' | 'dead_letter' (default 'dead_letter')
* - `eventType`: exact match on event_type
* - `limit`: cap rows returned (default 100, max 500)
*
* Response includes aggregate counts by status for quick health read.
*/
export const GET = withRouteHandler(
withAdminAuth(async (request: NextRequest) => {
try {
const parsed = await parseRequest(
adminV1ListOutboxContract,
request,
{},
{
validationErrorResponse: (error) =>
invalidOutboxQueryResponse(getValidationErrorMessage(error, 'Invalid outbox query')),
}
)
if (!parsed.success) return parsed.response
const { status, eventType, limit } = parsed.data.query
const whereConditions = [eq(outboxEvent.status, status)]
if (eventType) {
whereConditions.push(eq(outboxEvent.eventType, eventType))
}
const rows = await db
.select()
.from(outboxEvent)
.where(and(...whereConditions))
.orderBy(desc(outboxEvent.createdAt))
.limit(limit)
// Aggregate counts per (status, eventType) for at-a-glance health.
const counts = await db
.select({
status: outboxEvent.status,
eventType: outboxEvent.eventType,
count: sql<number>`count(*)::int`,
})
.from(outboxEvent)
.groupBy(outboxEvent.status, outboxEvent.eventType)
return NextResponse.json({
success: true,
filter: { status, eventType, limit },
rows,
counts,
})
} catch (error) {
logger.error('Failed to list outbox events', { error: toError(error).message })
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
})
)
@@ -0,0 +1,260 @@
/**
* GET /api/v1/admin/referral-campaigns
*
* List Stripe promotion codes with cursor-based pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 100)
* - starting_after: string (cursor — Stripe promotion code ID)
* - active: 'true' | 'false' (optional filter)
*
* POST /api/v1/admin/referral-campaigns
*
* Create a Stripe coupon and an associated promotion code.
*
* Body:
* - name: string (required) — Display name for the coupon
* - percentOff: number (required, 1100) — Percentage discount
* - code: string | null (optional, min 6 chars, auto-uppercased) — Desired code
* - duration: 'once' | 'repeating' | 'forever' (default: 'once')
* - durationInMonths: number (required when duration is 'repeating')
* - maxRedemptions: number (optional) — Total redemption cap
* - expiresAt: ISO 8601 string (optional) — Promotion code expiry
* - appliesTo: ('pro' | 'team' | 'pro_6000' | 'pro_25000' | 'team_6000' | 'team_25000')[] (optional)
* Restrict coupon to specific plans. Broad values ('pro', 'team') match all tiers.
*/
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type Stripe from 'stripe'
import {
type AdminV1PromoCode,
type AdminV1ReferralCampaignAppliesTo,
type AdminV1ReferralCampaignDuration,
adminV1CreateReferralCampaignContract,
adminV1ListReferralCampaignsContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { isPro, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans } from '@/lib/billing/plans'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminPromoCodes')
function formatPromoCode(promo: {
id: string
code: string
coupon: {
id: string
name: string | null
percent_off: number | null
duration: string
duration_in_months: number | null
applies_to?: { products: string[] }
}
max_redemptions: number | null
expires_at: number | null
active: boolean
times_redeemed: number
created: number
}): AdminV1PromoCode {
return {
id: promo.id,
code: promo.code,
couponId: promo.coupon.id,
name: promo.coupon.name ?? '',
percentOff: promo.coupon.percent_off ?? 0,
duration: promo.coupon.duration,
durationInMonths: promo.coupon.duration_in_months,
appliesToProductIds: promo.coupon.applies_to?.products ?? null,
maxRedemptions: promo.max_redemptions,
expiresAt: promo.expires_at ? new Date(promo.expires_at * 1000).toISOString() : null,
active: promo.active,
timesRedeemed: promo.times_redeemed,
createdAt: new Date(promo.created * 1000).toISOString(),
}
}
/**
* Resolve appliesTo values to unique Stripe product IDs.
* Broad categories ('pro', 'team') match all tiers via isPro/isTeam.
* Specific plan names ('pro_6000', 'team_25000') match exactly.
*/
async function resolveProductIds(
stripe: Stripe,
targets: AdminV1ReferralCampaignAppliesTo[]
): Promise<string[]> {
const plans = getPlans()
const priceIds: string[] = []
const broadMatchers: Record<string, (name: string) => boolean> = {
pro: isPro,
team: isTeam,
}
for (const plan of plans) {
const matches = targets.some((target) => {
const matcher = broadMatchers[target]
return matcher ? matcher(plan.name) : plan.name === target
})
if (!matches) continue
if (plan.priceId) priceIds.push(plan.priceId)
if (plan.annualDiscountPriceId) priceIds.push(plan.annualDiscountPriceId)
}
const results = await Promise.allSettled(
priceIds.map(async (priceId) => {
const price = await stripe.prices.retrieve(priceId)
return typeof price.product === 'string' ? price.product : price.product.id
})
)
const failures = results.filter((r) => r.status === 'rejected')
if (failures.length > 0) {
logger.error('Failed to resolve all Stripe products for appliesTo', {
failed: failures.length,
total: priceIds.length,
})
throw new Error('Could not resolve all Stripe products for the specified plan categories.')
}
const productIds = new Set<string>()
for (const r of results) {
if (r.status === 'fulfilled') productIds.add(r.value)
}
return [...productIds]
}
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
const parsed = await parseRequest(
adminV1ListReferralCampaignsContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
}
)
if (!parsed.success) return parsed.response
const query = parsed.data.query
const listParams: Record<string, unknown> = { limit: query.limit }
if (query.starting_after) listParams.starting_after = query.starting_after
if (query.active !== undefined) listParams.active = query.active
const promoCodes = await stripe.promotionCodes.list(listParams)
const data = promoCodes.data.map(formatPromoCode)
logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`)
return NextResponse.json({
data,
hasMore: promoCodes.has_more,
...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}),
})
} catch (error) {
logger.error('Admin API: Failed to list promotion codes', { error })
return internalErrorResponse('Failed to list promotion codes')
}
})
)
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
try {
const stripe = requireStripeClient()
const parsed = await parseRequest(
adminV1CreateReferralCampaignContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
invalidJson: 'throw',
}
)
if (!parsed.success) return parsed.response
const { name, percentOff, code, duration, durationInMonths, maxRedemptions, expiresAt } =
parsed.data.body
const appliesTo = parsed.data.body.appliesTo ?? undefined
const effectiveDuration: AdminV1ReferralCampaignDuration = duration
let appliesToProducts: string[] | undefined
if (appliesTo?.length) {
appliesToProducts = await resolveProductIds(stripe, appliesTo)
if (appliesToProducts.length === 0) {
return badRequestResponse(
'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.'
)
}
}
const coupon = await stripe.coupons.create({
name: name.trim(),
percent_off: percentOff,
duration: effectiveDuration,
...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}),
...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}),
})
let promoCode
try {
const promoParams: Stripe.PromotionCodeCreateParams = {
coupon: coupon.id,
...(code ? { code: code.trim().toUpperCase() } : {}),
...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}),
...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}),
}
promoCode = await stripe.promotionCodes.create(promoParams)
} catch (promoError) {
try {
await stripe.coupons.del(coupon.id)
} catch (cleanupError) {
logger.error(
'Admin API: Failed to clean up orphaned coupon after promo code creation failed',
{
couponId: coupon.id,
cleanupError,
}
)
}
throw promoError
}
logger.info('Admin API: Created Stripe promotion code', {
promoCodeId: promoCode.id,
code: promoCode.code,
couponId: coupon.id,
percentOff,
duration: effectiveDuration,
...(appliesTo ? { appliesTo } : {}),
})
return singleResponse(formatPromoCode(promoCode))
} catch (error) {
if (
error instanceof Error &&
'type' in error &&
(error as { type: string }).type === 'StripeInvalidRequestError'
) {
logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message })
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to create promotion code', { error })
return internalErrorResponse('Failed to create promotion code')
}
})
)
+95
View File
@@ -0,0 +1,95 @@
/**
* Admin API Response Helpers
*
* Consistent response formatting for all Admin API endpoints.
*/
import { NextResponse } from 'next/server'
import type { z } from 'zod'
import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server'
import type {
AdminErrorResponse,
AdminListResponse,
AdminSingleResponse,
PaginationMeta,
} from '@/app/api/v1/admin/types'
/**
* Create a successful list response with pagination
*/
export function listResponse<T>(
data: T[],
pagination: PaginationMeta
): NextResponse<AdminListResponse<T>> {
return NextResponse.json({ data, pagination })
}
/**
* Create a successful single resource response
*/
export function singleResponse<T>(data: T): NextResponse<AdminSingleResponse<T>> {
return NextResponse.json({ data })
}
/**
* Create an error response
*/
export function errorResponse(
code: string,
message: string,
status: number,
details?: unknown
): NextResponse<AdminErrorResponse> {
const body: AdminErrorResponse = {
error: { code, message },
}
if (details !== undefined) {
body.error.details = details
}
return NextResponse.json(body, { status })
}
// =============================================================================
// Common Error Responses
// =============================================================================
export function unauthorizedResponse(message = 'Authentication required'): NextResponse {
return errorResponse('UNAUTHORIZED', message, 401)
}
export function forbiddenResponse(message = 'Access denied'): NextResponse {
return errorResponse('FORBIDDEN', message, 403)
}
export function notFoundResponse(resource: string): NextResponse {
return errorResponse('NOT_FOUND', `${resource} not found`, 404)
}
export function badRequestResponse(message: string, details?: unknown): NextResponse {
return errorResponse('BAD_REQUEST', message, 400, details)
}
export function adminValidationErrorResponse(error: z.ZodError): NextResponse {
return badRequestResponse(
getValidationErrorMessage(error, 'Invalid request body'),
serializeZodIssues(error)
)
}
export function adminInvalidJsonResponse(): NextResponse {
return badRequestResponse('Request body must be valid JSON')
}
export function internalErrorResponse(message = 'Internal server error'): NextResponse {
return errorResponse('INTERNAL_ERROR', message, 500)
}
export function notConfiguredResponse(): NextResponse {
return errorResponse(
'NOT_CONFIGURED',
'Admin API is not configured. Set ADMIN_API_KEY environment variable.',
503
)
}
@@ -0,0 +1,182 @@
/**
* GET /api/v1/admin/subscriptions/[id]
*
* Get subscription details.
*
* Response: AdminSingleResponse<AdminSubscription>
*
* DELETE /api/v1/admin/subscriptions/[id]
*
* Cancel a subscription by triggering Stripe cancellation.
* The Stripe webhook handles all cleanup (same as platform cancellation):
* - Updates subscription status to canceled
* - Bills final period overages
* - Resets usage
* - Restores member Pro subscriptions (for team/enterprise)
* - Deletes organization (for team/enterprise)
* - Syncs usage limits to free tier
*
* Query Parameters:
* - atPeriodEnd?: boolean - Schedule cancellation at period end instead of immediate (default: false)
* - reason?: string - Reason for cancellation (for audit logging)
*
* Response: { success: true, message: string, subscriptionId: string, atPeriodEnd: boolean }
*/
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import {
adminV1CancelSubscriptionContract,
adminV1GetSubscriptionContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminSubscription } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminSubscriptionDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetSubscriptionContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: subscriptionId } = parsed.data.params
try {
const [subData] = await db
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.limit(1)
if (!subData) {
return notFoundResponse('Subscription')
}
logger.info(`Admin API: Retrieved subscription ${subscriptionId}`)
return singleResponse(toAdminSubscription(subData))
} catch (error) {
logger.error('Admin API: Failed to get subscription', { error, subscriptionId })
return internalErrorResponse('Failed to get subscription')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1CancelSubscriptionContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: subscriptionId } = parsed.data.params
const { atPeriodEnd, reason: rawReason } = parsed.data.query
const reason = rawReason || 'Admin cancellation (no reason provided)'
try {
const [existing] = await db
.select()
.from(subscription)
.where(eq(subscription.id, subscriptionId))
.limit(1)
if (!existing) {
return notFoundResponse('Subscription')
}
if (existing.status === 'canceled') {
return badRequestResponse('Subscription is already canceled')
}
if (!existing.stripeSubscriptionId) {
return badRequestResponse('Subscription has no Stripe subscription ID')
}
if (atPeriodEnd) {
await db.transaction(async (tx) => {
await tx
.update(subscription)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscription.id, subscriptionId))
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, {
stripeSubscriptionId: existing.stripeSubscriptionId,
subscriptionId: existing.id,
reason: reason ?? 'admin-cancel-at-period-end',
})
})
logger.info(
'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)',
{
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
periodEnd: existing.periodEnd,
reason,
}
)
return singleResponse({
success: true,
message: 'Subscription scheduled to cancel at period end.',
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
atPeriodEnd: true,
periodEnd: existing.periodEnd?.toISOString() ?? null,
})
}
// Immediate cancellation — stays synchronous. Stripe's
// `customer.subscription.deleted` webhook triggers full cleanup
// (overage bill, usage reset, Pro restore, org delete) via
// `handleSubscriptionDeleted`, so no outbox needed here.
const stripe = requireStripeClient()
await stripe.subscriptions.cancel(
existing.stripeSubscriptionId,
{ prorate: true, invoice_now: true },
{ idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` }
)
logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', {
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
plan: existing.plan,
referenceId: existing.referenceId,
reason,
})
return singleResponse({
success: true,
message: 'Subscription cancellation triggered. Webhook will complete cleanup.',
subscriptionId,
stripeSubscriptionId: existing.stripeSubscriptionId,
atPeriodEnd: false,
})
} catch (error) {
logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId })
return internalErrorResponse('Failed to cancel subscription')
}
})
)
@@ -0,0 +1,84 @@
/**
* GET /api/v1/admin/subscriptions
*
* List all subscriptions with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
* - plan: string (optional) - Filter by plan (free, pro, team, enterprise)
* - status: string (optional) - Filter by status (active, canceled, etc.)
*
* Response: AdminListResponse<AdminSubscription>
*/
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, type SQL } from 'drizzle-orm'
import { adminV1ListSubscriptionsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminSubscription,
createPaginationMeta,
toAdminSubscription,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminSubscriptionsAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1ListSubscriptionsContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
}
)
if (!parsed.success) return parsed.response
const { limit, offset, plan: planFilter, status: statusFilter } = parsed.data.query
try {
const conditions: SQL<unknown>[] = []
if (planFilter) {
conditions.push(eq(subscription.plan, planFilter))
}
if (statusFilter) {
conditions.push(eq(subscription.status, statusFilter))
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [countResult, subscriptions] = await Promise.all([
db.select({ total: count() }).from(subscription).where(whereClause),
db
.select()
.from(subscription)
.where(whereClause)
.orderBy(subscription.plan)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminSubscription[] = subscriptions.map(toAdminSubscription)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} subscriptions (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list subscriptions', { error })
return internalErrorResponse('Failed to list subscriptions')
}
})
)
+684
View File
@@ -0,0 +1,684 @@
/**
* Admin API Types
*
* This file defines the types for the Admin API endpoints.
* All responses follow a consistent structure for predictability.
*/
import type {
auditLog,
member,
organization,
subscription,
user,
userStats,
workflow,
workflowFolder,
workspace,
} from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
// =============================================================================
// Database Model Types (inferred from schema)
// =============================================================================
export type DbUser = InferSelectModel<typeof user>
export type DbWorkspace = InferSelectModel<typeof workspace>
export type DbWorkflow = InferSelectModel<typeof workflow>
export type DbWorkflowFolder = InferSelectModel<typeof workflowFolder>
export type DbOrganization = InferSelectModel<typeof organization>
export type DbSubscription = InferSelectModel<typeof subscription>
export type DbMember = InferSelectModel<typeof member>
export type DbUserStats = InferSelectModel<typeof userStats>
// =============================================================================
// Pagination
// =============================================================================
export interface PaginationParams {
limit: number
offset: number
}
export interface PaginationMeta {
total: number
limit: number
offset: number
hasMore: boolean
}
export const DEFAULT_LIMIT = 50
export const MAX_LIMIT = 250
export function parsePaginationParams(url: URL): PaginationParams {
return {
limit: parsePaginationNumber(url.searchParams.get('limit'), DEFAULT_LIMIT, MAX_LIMIT),
offset: parsePaginationNumber(url.searchParams.get('offset'), 0),
}
}
function parsePaginationNumber(value: string | null, fallback: number, max?: number): number {
const parsed = value ? Number.parseInt(value, 10) : fallback
if (!Number.isInteger(parsed) || parsed < 1) return fallback
return max === undefined ? parsed : Math.min(parsed, max)
}
export function createPaginationMeta(total: number, limit: number, offset: number): PaginationMeta {
return {
total,
limit,
offset,
hasMore: offset + limit < total,
}
}
// =============================================================================
// API Response Types
// =============================================================================
export interface AdminListResponse<T> {
data: T[]
pagination: PaginationMeta
}
export interface AdminSingleResponse<T> {
data: T
}
export interface AdminErrorResponse {
error: {
code: string
message: string
details?: unknown
}
}
// =============================================================================
// User Types
// =============================================================================
export interface AdminUser {
id: string
name: string
email: string
emailVerified: boolean
image: string | null
createdAt: string
updatedAt: string
}
export function toAdminUser(dbUser: DbUser): AdminUser {
return {
id: dbUser.id,
name: dbUser.name,
email: dbUser.email,
emailVerified: dbUser.emailVerified,
image: dbUser.image,
createdAt: dbUser.createdAt.toISOString(),
updatedAt: dbUser.updatedAt.toISOString(),
}
}
// =============================================================================
// Workspace Types
// =============================================================================
export interface AdminWorkspace {
id: string
name: string
ownerId: string
createdAt: string
updatedAt: string
}
export interface AdminWorkspaceDetail extends AdminWorkspace {
workflowCount: number
folderCount: number
}
export function toAdminWorkspace(dbWorkspace: DbWorkspace): AdminWorkspace {
return {
id: dbWorkspace.id,
name: dbWorkspace.name,
ownerId: dbWorkspace.ownerId,
createdAt: dbWorkspace.createdAt.toISOString(),
updatedAt: dbWorkspace.updatedAt.toISOString(),
}
}
// =============================================================================
// Folder Types
// =============================================================================
export interface AdminFolder {
id: string
name: string
parentId: string | null
color: string | null
sortOrder: number
createdAt: string
updatedAt: string
}
export function toAdminFolder(dbFolder: DbWorkflowFolder): AdminFolder {
return {
id: dbFolder.id,
name: dbFolder.name,
parentId: dbFolder.parentId,
color: dbFolder.color,
sortOrder: dbFolder.sortOrder,
createdAt: dbFolder.createdAt.toISOString(),
updatedAt: dbFolder.updatedAt.toISOString(),
}
}
// =============================================================================
// Workflow Types
// =============================================================================
export interface AdminWorkflow {
id: string
name: string
description: string | null
workspaceId: string | null
folderId: string | null
isDeployed: boolean
deployedAt: string | null
runCount: number
lastRunAt: string | null
createdAt: string
updatedAt: string
}
export interface AdminWorkflowDetail extends AdminWorkflow {
blockCount: number
edgeCount: number
}
export type AdminWorkflowSource = Pick<
DbWorkflow,
| 'id'
| 'name'
| 'description'
| 'workspaceId'
| 'folderId'
| 'isDeployed'
| 'deployedAt'
| 'runCount'
| 'lastRunAt'
| 'createdAt'
| 'updatedAt'
>
export function toAdminWorkflow(dbWorkflow: AdminWorkflowSource): AdminWorkflow {
return {
id: dbWorkflow.id,
name: dbWorkflow.name,
description: dbWorkflow.description,
workspaceId: dbWorkflow.workspaceId,
folderId: dbWorkflow.folderId,
isDeployed: dbWorkflow.isDeployed,
deployedAt: dbWorkflow.deployedAt?.toISOString() ?? null,
runCount: dbWorkflow.runCount,
lastRunAt: dbWorkflow.lastRunAt?.toISOString() ?? null,
createdAt: dbWorkflow.createdAt.toISOString(),
updatedAt: dbWorkflow.updatedAt.toISOString(),
}
}
// =============================================================================
// Workflow Variable Types
// =============================================================================
export type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
export interface WorkflowVariable {
id: string
name: string
type: VariableType
value: unknown
}
// =============================================================================
// Export/Import Types
// =============================================================================
export interface WorkflowExportState {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
metadata?: {
name?: string
description?: string
exportedAt?: string
}
variables?: Record<string, WorkflowVariable>
}
export interface WorkflowExportPayload {
version: '1.0'
exportedAt: string
workflow: {
id: string
name: string
description: string | null
workspaceId: string | null
folderId: string | null
}
state: WorkflowExportState
}
export interface FolderExportPayload {
id: string
name: string
parentId: string | null
}
export interface WorkspaceExportPayload {
version: '1.0'
exportedAt: string
workspace: {
id: string
name: string
}
workflows: Array<{
workflow: WorkflowExportPayload['workflow']
state: WorkflowExportState
}>
folders: FolderExportPayload[]
}
// =============================================================================
// Import Types
// =============================================================================
export interface WorkflowImportRequest {
workspaceId: string
folderId?: string
name?: string
workflow: WorkflowExportPayload | WorkflowExportState | string
}
export interface WorkspaceImportRequest {
workflows: Array<{
content: string | WorkflowExportPayload | WorkflowExportState
name?: string
folderPath?: string[]
}>
}
export interface ImportResult {
workflowId: string
name: string
success: boolean
error?: string
}
export interface WorkspaceImportResponse {
imported: number
failed: number
results: ImportResult[]
}
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Parse workflow variables from database JSON format to Record format.
* Handles both legacy Array and current Record<string, Variable> formats.
*/
export function parseWorkflowVariables(
dbVariables: DbWorkflow['variables']
): Record<string, WorkflowVariable> | undefined {
if (!dbVariables) return undefined
try {
const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
// Handle legacy Array format by converting to Record
if (Array.isArray(varsObj)) {
const result: Record<string, WorkflowVariable> = {}
for (const v of varsObj) {
result[v.id] = {
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}
}
return result
}
// Already Record format - normalize and return
if (typeof varsObj === 'object' && varsObj !== null) {
const result: Record<string, WorkflowVariable> = {}
for (const [key, v] of Object.entries(varsObj)) {
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
result[key] = {
id: variable.id,
name: variable.name,
type: variable.type,
value: variable.value,
}
}
return result
}
} catch {
// pass
}
return undefined
}
/**
* Extract workflow metadata from various export formats.
* Handles both full export payload and raw state formats.
*/
export function extractWorkflowMetadata(
workflowJson: unknown,
overrideName?: string
): { name: string; description: string } {
const defaults = {
name: overrideName || 'Imported Workflow',
description: 'Imported via Admin API',
}
if (!workflowJson || typeof workflowJson !== 'object') {
return defaults
}
const parsed = workflowJson as Record<string, unknown>
const name =
overrideName ||
getNestedString(parsed, 'workflow.name') ||
getNestedString(parsed, 'state.metadata.name') ||
getNestedString(parsed, 'metadata.name') ||
defaults.name
const description =
getNestedString(parsed, 'workflow.description') ||
getNestedString(parsed, 'state.metadata.description') ||
getNestedString(parsed, 'metadata.description') ||
defaults.description
return { name, description }
}
/**
* Safely get a nested string value from an object.
*/
function getNestedString(obj: Record<string, unknown>, path: string): string | undefined {
const parts = path.split('.')
let current: unknown = obj
for (const part of parts) {
if (current === null || typeof current !== 'object') {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
return typeof current === 'string' ? current : undefined
}
// =============================================================================
// Organization Types
// =============================================================================
export interface AdminOrganization {
id: string
name: string
slug: string
logo: string | null
orgUsageLimit: string | null
storageUsedBytes: number
departedMemberUsage: string
createdAt: string
updatedAt: string
}
export interface AdminOrganizationDetail extends AdminOrganization {
memberCount: number
subscription: AdminSubscription | null
}
export type AdminOrganizationSource = Pick<
DbOrganization,
| 'id'
| 'name'
| 'slug'
| 'logo'
| 'orgUsageLimit'
| 'storageUsedBytes'
| 'departedMemberUsage'
| 'createdAt'
| 'updatedAt'
>
export function toAdminOrganization(dbOrg: AdminOrganizationSource): AdminOrganization {
return {
id: dbOrg.id,
name: dbOrg.name,
slug: dbOrg.slug,
logo: dbOrg.logo,
orgUsageLimit: dbOrg.orgUsageLimit,
storageUsedBytes: dbOrg.storageUsedBytes,
departedMemberUsage: dbOrg.departedMemberUsage,
createdAt: dbOrg.createdAt.toISOString(),
updatedAt: dbOrg.updatedAt.toISOString(),
}
}
// =============================================================================
// Subscription Types
// =============================================================================
export interface AdminSubscription {
id: string
plan: string
referenceId: string
stripeCustomerId: string | null
stripeSubscriptionId: string | null
status: string | null
periodStart: string | null
periodEnd: string | null
cancelAtPeriodEnd: boolean | null
seats: number | null
trialStart: string | null
trialEnd: string | null
metadata: unknown
}
export function toAdminSubscription(dbSub: DbSubscription): AdminSubscription {
return {
id: dbSub.id,
plan: dbSub.plan,
referenceId: dbSub.referenceId,
stripeCustomerId: dbSub.stripeCustomerId,
stripeSubscriptionId: dbSub.stripeSubscriptionId,
status: dbSub.status,
periodStart: dbSub.periodStart?.toISOString() ?? null,
periodEnd: dbSub.periodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: dbSub.cancelAtPeriodEnd,
seats: dbSub.seats,
trialStart: dbSub.trialStart?.toISOString() ?? null,
trialEnd: dbSub.trialEnd?.toISOString() ?? null,
metadata: dbSub.metadata,
}
}
// =============================================================================
// Member Types
// =============================================================================
export interface AdminMember {
id: string
userId: string
organizationId: string
role: string
createdAt: string
// Joined user info
userName: string
userEmail: string
}
export interface AdminMemberDetail extends AdminMember {
// Billing/usage info from userStats
currentPeriodCost: string
currentUsageLimit: string | null
billingBlocked: boolean
}
// =============================================================================
// Workspace Member Types
// =============================================================================
export interface AdminWorkspaceMember {
id: string
workspaceId: string
userId: string
permissions: 'admin' | 'write' | 'read'
createdAt: string
updatedAt: string
userName: string
userEmail: string
userImage: string | null
}
// =============================================================================
// User Billing Types
// =============================================================================
interface AdminUserBilling {
userId: string
// User info
userName: string
userEmail: string
stripeCustomerId: string | null
// Usage stats
currentUsageLimit: string | null
currentPeriodCost: string
lastPeriodCost: string | null
billedOverageThisPeriod: string
storageUsedBytes: number
billingBlocked: boolean
// Copilot usage (active per-period baselines)
currentPeriodCopilotCost: string
lastPeriodCopilotCost: string | null
}
export interface AdminUserBillingWithSubscription extends AdminUserBilling {
subscriptions: AdminSubscription[]
organizationMemberships: Array<{
organizationId: string
organizationName: string
role: string
}>
}
// =============================================================================
// Organization Billing Summary Types
// =============================================================================
export interface AdminOrganizationBillingSummary {
organizationId: string
organizationName: string
subscriptionPlan: string
subscriptionStatus: string
// Seats
totalSeats: number
usedSeats: number
availableSeats: number
// Usage
totalCurrentUsage: number
totalUsageLimit: number
minimumBillingAmount: number
averageUsagePerMember: number
usagePercentage: number
// Billing period
billingPeriodStart: string | null
billingPeriodEnd: string | null
// Alerts
membersOverLimit: number
membersNearLimit: number
}
export interface AdminSeatAnalytics {
organizationId: string
organizationName: string
currentSeats: number
maxSeats: number
availableSeats: number
subscriptionPlan: string
canAddSeats: boolean
utilizationRate: number
}
export interface AdminDeploymentVersion {
id: string
version: number
name: string | null
isActive: boolean
createdAt: string
createdBy: string | null
deployedByName: string | null
}
export interface AdminDeployResult {
isDeployed: boolean
version: number
deployedAt: string
warnings?: string[]
}
export interface AdminUndeployResult {
isDeployed: boolean
warnings?: string[]
}
// =============================================================================
// Audit Log Types
// =============================================================================
export type DbAuditLog = InferSelectModel<typeof auditLog>
export interface AdminAuditLog {
id: string
workspaceId: string | null
actorId: string | null
actorName: string | null
actorEmail: string | null
action: string
resourceType: string
resourceId: string | null
resourceName: string | null
description: string | null
metadata: unknown
ipAddress: string | null
userAgent: string | null
createdAt: string
}
export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog {
return {
id: dbLog.id,
workspaceId: dbLog.workspaceId,
actorId: dbLog.actorId,
actorName: dbLog.actorName,
actorEmail: dbLog.actorEmail,
action: dbLog.action,
resourceType: dbLog.resourceType,
resourceId: dbLog.resourceId,
resourceName: dbLog.resourceName,
description: dbLog.description,
metadata: dbLog.metadata,
ipAddress: dbLog.ipAddress,
userAgent: dbLog.userAgent,
createdAt: dbLog.createdAt.toISOString(),
}
}
@@ -0,0 +1,277 @@
/**
* GET /api/v1/admin/users/[id]/billing
*
* Get user billing information including usage stats, subscriptions, and org memberships.
*
* Response: AdminSingleResponse<AdminUserBillingWithSubscription>
*
* PATCH /api/v1/admin/users/[id]/billing
*
* Update user billing settings with proper validation.
*
* Body:
* - currentUsageLimit?: number | null - Usage limit (null to use default)
* - billingBlocked?: boolean - Block/unblock billing
* - currentPeriodCost?: number - Reset/adjust current period cost (use with caution)
* - reason?: string - Reason for the change (for audit logging)
*
* Response: AdminSingleResponse<{ success: true, updated: string[], warnings: string[] }>
*/
import { db } from '@sim/db'
import { member, organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { eq, or } from 'drizzle-orm'
import {
adminV1GetUserBillingContract,
adminV1UpdateUserBillingContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminInvalidJsonResponse,
adminValidationErrorResponse,
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type AdminUserBillingWithSubscription,
toAdminSubscription,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminUserBillingAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetUserBillingContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: userId } = parsed.data.params
try {
const [userData] = await db
.select({
id: user.id,
name: user.name,
email: user.email,
stripeCustomerId: user.stripeCustomerId,
})
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1)
// currentPeriodCost is now only a baseline; canonical current-period usage
// (baseline + attributed usage_log, refresh-adjusted) comes from the same
// helper users see, so admin reflects real usage instead of a stale 0.
const usage = await getUserUsageData(userId)
const memberOrgs = await db
.select({
organizationId: member.organizationId,
organizationName: organization.name,
role: member.role,
})
.from(member)
.innerJoin(organization, eq(member.organizationId, organization.id))
.where(eq(member.userId, userId))
const orgIds = memberOrgs.map((m) => m.organizationId)
const subscriptions = await db
.select()
.from(subscription)
.where(
orgIds.length > 0
? or(
eq(subscription.referenceId, userId),
...orgIds.map((orgId) => eq(subscription.referenceId, orgId))
)
: eq(subscription.referenceId, userId)
)
const data: AdminUserBillingWithSubscription = {
userId: userData.id,
userName: userData.name,
userEmail: userData.email,
stripeCustomerId: userData.stripeCustomerId,
currentUsageLimit: stats?.currentUsageLimit ?? null,
currentPeriodCost: usage.currentUsage.toString(),
lastPeriodCost: stats?.lastPeriodCost ?? null,
billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0',
storageUsedBytes: stats?.storageUsedBytes ?? 0,
billingBlocked: stats?.billingBlocked ?? false,
currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0',
lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null,
subscriptions: subscriptions.map(toAdminSubscription),
organizationMemberships: memberOrgs.map((m) => ({
organizationId: m.organizationId,
organizationName: m.organizationName,
role: m.role,
})),
}
logger.info(`Admin API: Retrieved billing for user ${userId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get user billing', { error, userId })
return internalErrorResponse('Failed to get user billing')
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1UpdateUserBillingContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
invalidJsonResponse: adminInvalidJsonResponse,
})
if (!parsed.success) return parsed.response
const { id: userId } = parsed.data.params
try {
const {
currentUsageLimit,
billingBlocked,
currentPeriodCost,
reason: providedReason,
} = parsed.data.body
const reason = providedReason || 'Admin update (no reason provided)'
const [userData] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [existingStats] = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
const userSubscription = await getHighestPrioritySubscription(userId)
const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId)
const [orgMembership] = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
const updateData: Record<string, unknown> = {}
const updated: string[] = []
const warnings: string[] = []
if (currentUsageLimit !== undefined) {
if (isOrgScopedMember && orgMembership) {
warnings.push(
'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.'
)
}
if (currentUsageLimit === null) {
updateData.currentUsageLimit = null
} else {
const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0')
if (currentUsageLimit < currentCost) {
warnings.push(
`New limit ($${currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.`
)
}
updateData.currentUsageLimit = currentUsageLimit.toFixed(2)
}
updateData.usageLimitUpdatedAt = new Date()
updated.push('currentUsageLimit')
}
if (billingBlocked !== undefined) {
if (billingBlocked === false && existingStats?.billingBlocked === true) {
warnings.push(
'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.'
)
}
updateData.billingBlocked = billingBlocked
// Clear the reason when unblocking
if (billingBlocked === false) {
updateData.billingBlockedReason = null
}
updated.push('billingBlocked')
}
if (currentPeriodCost !== undefined) {
const previousCost = existingStats?.currentPeriodCost || '0'
warnings.push(
`Manually adjusting currentPeriodCost from $${previousCost} to $${currentPeriodCost.toFixed(2)}. This may affect billing accuracy.`
)
updateData.currentPeriodCost = currentPeriodCost.toFixed(2)
updated.push('currentPeriodCost')
}
if (updated.length === 0) {
return badRequestResponse('No valid fields to update')
}
if (existingStats) {
await db.update(userStats).set(updateData).where(eq(userStats.userId, userId))
} else {
await db.insert(userStats).values({
id: generateShortId(),
userId,
...updateData,
})
}
logger.info(`Admin API: Updated billing for user ${userId}`, {
updated,
warnings,
reason,
previousValues: existingStats
? {
currentUsageLimit: existingStats.currentUsageLimit,
billingBlocked: existingStats.billingBlocked,
currentPeriodCost: existingStats.currentPeriodCost,
}
: null,
newValues: updateData,
isTeamMember: !!orgMembership,
})
return singleResponse({
success: true,
updated,
warnings,
reason,
})
} catch (error) {
logger.error('Admin API: Failed to update user billing', { error, userId })
return internalErrorResponse('Failed to update user billing')
}
})
)
@@ -0,0 +1,57 @@
/**
* GET /api/v1/admin/users/[id]
*
* Get user details.
*
* Response: AdminSingleResponse<AdminUser>
*/
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { adminV1GetUserContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { toAdminUser } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminUserDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetUserContract, request, context, {
validationErrorResponse: adminValidationErrorResponse,
})
if (!parsed.success) return parsed.response
const { id: userId } = parsed.data.params
try {
const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
if (!userData) {
return notFoundResponse('User')
}
const data = toAdminUser(userData)
logger.info(`Admin API: Retrieved user ${userId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get user', { error, userId })
return internalErrorResponse('Failed to get user')
}
})
)
+62
View File
@@ -0,0 +1,62 @@
/**
* GET /api/v1/admin/users
*
* List all users with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminUser>
*/
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count } from 'drizzle-orm'
import { adminV1ListUsersContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
adminValidationErrorResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminUser, createPaginationMeta, toAdminUser } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminUsersAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(
adminV1ListUsersContract,
request,
{},
{
validationErrorResponse: adminValidationErrorResponse,
}
)
if (!parsed.success) return parsed.response
const { limit, offset } = parsed.data.query
try {
const [countResult, users] = await Promise.all([
db.select({ total: count() }).from(user),
db.select().from(user).orderBy(user.name).limit(limit).offset(offset),
])
const total = countResult[0].total
const data: AdminUser[] = users.map(toAdminUser)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} users (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list users', { error })
return internalErrorResponse('Failed to list users')
}
})
)
@@ -0,0 +1,122 @@
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import {
adminV1DeployWorkflowContract,
adminV1UndeployWorkflowContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeployResult, AdminUndeployResult } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowDeployAPI')
export const maxDuration = 120
interface RouteParams {
id: string
}
/**
* POST — Deploy a workflow via admin API.
*
* `userId` is set to the workflow owner so that webhook credential resolution
* (OAuth token lookups for providers like Airtable, Attio, etc.) uses a real
* user. `actorId` is set to `'admin-api'` so that the `deployedBy` field on
* the deployment version and audit log entries are correctly attributed to an
* admin action rather than the workflow owner.
*/
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1DeployWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
const requestId = generateRequestId()
try {
const workflowRecord = await getActiveWorkflowRecord(workflowId)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const result = await performFullDeploy({
workflowId,
userId: workflowRecord.userId,
workflowName: workflowRecord.name,
requestId,
request,
actorId: 'admin-api',
})
if (!result.success) {
if (result.errorCode === 'not_found') return notFoundResponse('Workflow state')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to deploy workflow')
}
logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: result.version!,
deployedAt: result.deployedAt!.toISOString(),
warnings: result.warnings,
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to deploy workflow')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1UndeployWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
const requestId = generateRequestId()
try {
const workflowRecord = await getActiveWorkflowRecord(workflowId)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const result = await performFullUndeploy({
workflowId,
userId: workflowRecord.userId,
requestId,
actorId: 'admin-api',
})
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {
isDeployed: false,
warnings: result.warnings,
}
return singleResponse(response)
} catch (error) {
logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to undeploy workflow')
}
})
)
@@ -0,0 +1,95 @@
/**
* GET /api/v1/admin/workflows/[id]/export
*
* Export a single workflow as JSON (raw, unsanitized for admin backup/restore).
*
* Response: AdminSingleResponse<WorkflowExportPayload>
*/
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { adminV1ExportWorkflowContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
parseWorkflowVariables,
type WorkflowExportPayload,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowExportAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ExportWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
try {
const [workflowData] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowData) {
return notFoundResponse('Workflow')
}
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return notFoundResponse('Workflow state')
}
const variables = parseWorkflowVariables(workflowData.variables)
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: workflowData.name,
description: workflowData.description ?? undefined,
exportedAt: new Date().toISOString(),
},
variables,
}
const exportPayload: WorkflowExportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
workflow: {
id: workflowData.id,
name: workflowData.name,
description: workflowData.description,
workspaceId: workflowData.workspaceId,
folderId: workflowData.folderId,
},
state,
}
logger.info(`Admin API: Exported workflow ${workflowId}`)
return singleResponse(exportPayload)
} catch (error) {
logger.error('Admin API: Failed to export workflow', { error, workflowId })
return internalErrorResponse('Failed to export workflow')
}
})
)
@@ -0,0 +1,117 @@
/**
* GET /api/v1/admin/workflows/[id]
*
* Get workflow details including block and edge counts.
*
* Response: AdminSingleResponse<AdminWorkflowDetail>
*
* DELETE /api/v1/admin/workflows/[id]
*
* Delete a workflow and all its associated data.
*
* Response: { success: true, workflowId: string }
*/
import { db } from '@sim/db'
import { workflowBlocks, workflowEdges } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { count, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import {
adminV1DeleteWorkflowContract,
adminV1GetWorkflowContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performDeleteWorkflow } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminWorkflowDetail, toAdminWorkflow } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
try {
const workflowData = await getActiveWorkflowRecord(workflowId)
if (!workflowData) {
return notFoundResponse('Workflow')
}
const [blockCountResult, edgeCountResult] = await Promise.all([
db
.select({ count: count() })
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, workflowId)),
db
.select({ count: count() })
.from(workflowEdges)
.where(eq(workflowEdges.workflowId, workflowId)),
])
const data: AdminWorkflowDetail = {
...toAdminWorkflow(workflowData),
blockCount: blockCountResult[0].count,
edgeCount: edgeCountResult[0].count,
}
logger.info(`Admin API: Retrieved workflow ${workflowId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get workflow', { error, workflowId })
return internalErrorResponse('Failed to get workflow')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1DeleteWorkflowContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
try {
const workflowData = await getActiveWorkflowRecord(workflowId)
if (!workflowData) {
return notFoundResponse('Workflow')
}
const result = await performDeleteWorkflow({
workflowId,
userId: workflowData.userId,
skipLastWorkflowGuard: true,
requestId: `admin-workflow-${workflowId}`,
actorId: 'admin-api',
})
if (!result.success) {
return internalErrorResponse(result.error || 'Failed to delete workflow')
}
logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`)
return NextResponse.json({ success: true, workflowId })
} catch (error) {
logger.error('Admin API: Failed to delete workflow', { error, workflowId })
return internalErrorResponse('Failed to delete workflow')
}
})
)
@@ -0,0 +1,73 @@
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { adminV1ActivateWorkflowVersionContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
export const maxDuration = 120
interface RouteParams {
id: string
versionId: string
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const requestId = generateRequestId()
const parsed = await parseRequest(adminV1ActivateWorkflowVersionContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId, versionId: versionNum } = parsed.data.params
try {
const workflowRecord = await getActiveWorkflowRecord(workflowId)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const result = await performActivateVersion({
workflowId,
version: versionNum,
userId: workflowRecord.userId,
workflow: workflowRecord as Record<string, unknown>,
requestId,
request,
actorId: 'admin-api',
})
if (!result.success) {
if (result.errorCode === 'not_found') return notFoundResponse('Deployment version')
if (result.errorCode === 'validation') return badRequestResponse(result.error!)
return internalErrorResponse(result.error || 'Failed to activate version')
}
logger.info(
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
)
return singleResponse({
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
warnings: result.warnings,
})
} catch (error) {
logger.error(
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
{ error }
)
return internalErrorResponse('Failed to activate deployment version')
}
})
)
@@ -0,0 +1,55 @@
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { adminV1ListWorkflowVersionsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { listWorkflowVersions } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminDeploymentVersion } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowVersionsAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkflowVersionsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params
try {
const workflowRecord = await getActiveWorkflowRecord(workflowId)
if (!workflowRecord) {
return notFoundResponse('Workflow')
}
const { versions } = await listWorkflowVersions(workflowId)
const response: AdminDeploymentVersion[] = versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
createdBy: v.createdBy,
deployedByName: v.deployedByName,
}))
logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`)
return singleResponse({ versions: response })
} catch (error) {
logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to list deployment versions')
}
})
)
@@ -0,0 +1,158 @@
/**
* POST /api/v1/admin/workflows/export
*
* Export multiple workflows as a ZIP file or JSON array (raw, unsanitized for admin backup/restore).
*
* Request Body:
* - ids: string[] - Array of workflow IDs to export
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip) - each workflow as JSON in root
* - JSON: AdminListResponse<WorkflowExportPayload[]>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { inArray } from 'drizzle-orm'
import JSZip from 'jszip'
import { NextResponse } from 'next/server'
import { adminV1ExportWorkflowsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
} from '@/app/api/v1/admin/responses'
import {
parseWorkflowVariables,
type WorkflowExportPayload,
type WorkflowExportState,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowsExportAPI')
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(adminV1ExportWorkflowsContract, request, {})
if (!parsed.success) return parsed.response
const { format } = parsed.data.query
const body = parsed.data.body
try {
const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids))
if (workflows.length === 0) {
return badRequestResponse('No workflows found with the provided IDs')
}
const workflowExports: WorkflowExportPayload[] = []
for (const wf of workflows) {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wf.variables)
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wf.name,
description: wf.description ?? undefined,
exportedAt: new Date().toISOString(),
},
variables,
}
const exportPayload: WorkflowExportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
workflow: {
id: wf.id,
name: wf.name,
description: wf.description,
workspaceId: wf.workspaceId,
folderId: wf.folderId,
},
state,
}
workflowExports.push(exportPayload)
} catch (error) {
logger.error(`Failed to load workflow ${wf.id}:`, { error })
}
}
logger.info(`Admin API: Exporting ${workflowExports.length} workflows`)
const auditExport = () => {
if (workflowExports.length === 0) return
recordAudit({
actorId: 'admin-api',
action: AuditAction.WORKFLOW_EXPORTED,
resourceType: AuditResourceType.WORKFLOW,
description: `Admin API exported ${workflowExports.length} workflow(s)`,
metadata: {
format,
requestedCount: body.ids.length,
exportedCount: workflowExports.length,
requestedIds: body.ids,
},
request,
})
}
if (format === 'json') {
auditExport()
return listResponse(workflowExports, {
total: workflowExports.length,
limit: workflowExports.length,
offset: 0,
hasMore: false,
})
}
const zip = new JSZip()
for (const exportPayload of workflowExports) {
const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json`
zip.file(filename, JSON.stringify(exportPayload, null, 2))
}
const zipBlob = await zip.generateAsync({ type: 'blob' })
const arrayBuffer = await zipBlob.arrayBuffer()
const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip`
auditExport()
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export workflows', { error, ids: body.ids })
return internalErrorResponse('Failed to export workflows')
}
})
)
@@ -0,0 +1,178 @@
/**
* POST /api/v1/admin/workflows/import
*
* Import a single workflow into a workspace.
*
* Request Body:
* {
* workspaceId: string, // Required: target workspace
* folderId?: string, // Optional: target folder
* name?: string, // Optional: override workflow name
* workflow: object | string // The workflow JSON (from export or raw state)
* }
*
* Response: { workflowId: string, name: string, success: true }
*/
import { db } from '@sim/db'
import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { adminV1ImportWorkflowContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import {
extractWorkflowMetadata,
type VariableType,
type WorkflowImportRequest,
type WorkflowVariable,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowImportAPI')
interface ImportSuccessResponse {
workflowId: string
name: string
success: true
}
export const POST = withRouteHandler(
withAdminAuth(async (request) => {
try {
const parsed = await parseRequest(adminV1ImportWorkflowContract, request, {})
if (!parsed.success) return parsed.response
const body = parsed.data.body as WorkflowImportRequest
const { workspaceId, folderId, name: overrideName } = body
const [workspaceData] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const workflowContent =
typeof body.workflow === 'string' ? body.workflow : JSON.stringify(body.workflow)
const { data: workflowData, errors } = parseWorkflowJson(workflowContent)
if (!workflowData || errors.length > 0) {
return badRequestResponse(`Invalid workflow: ${errors.join(', ')}`)
}
const parsedWorkflow =
typeof body.workflow === 'string'
? (() => {
try {
return JSON.parse(body.workflow)
} catch {
return null
}
})()
: body.workflow
const { name: workflowName, description: workflowDescription } = extractWorkflowMetadata(
parsedWorkflow,
overrideName
)
const workflowId = generateId()
const now = new Date()
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null)
await db.insert(workflow).values({
id: workflowId,
userId: workspaceData.ownerId,
workspaceId,
folderId: folderId || null,
name: dedupedName,
description: workflowDescription,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`)
}
if (
workflowData.variables &&
typeof workflowData.variables === 'object' &&
!Array.isArray(workflowData.variables)
) {
const variablesRecord: Record<string, WorkflowVariable> = {}
const vars = workflowData.variables as Record<
string,
{ id?: string; name: string; type?: VariableType; value: unknown }
>
Object.entries(vars).forEach(([key, v]) => {
const varId = v.id || key
variablesRecord[varId] = {
id: varId,
name: v.name,
type: v.type ?? 'string',
value: v.value,
}
})
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
} else if (workflowData.variables && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<string, WorkflowVariable> = {}
workflowData.variables.forEach((v) => {
const varId = v.id || generateId()
variablesRecord[varId] = {
id: varId,
name: v.name,
type: (v.type as VariableType) ?? 'string',
value: v.value,
}
})
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
}
logger.info(
`Admin API: Imported workflow ${workflowId} (${dedupedName}) into workspace ${workspaceId}`
)
const response: ImportSuccessResponse = {
workflowId,
name: dedupedName,
success: true,
}
return NextResponse.json(response)
} catch (error) {
logger.error('Admin API: Failed to import workflow', { error })
return internalErrorResponse('Failed to import workflow')
}
})
)
@@ -0,0 +1,68 @@
/**
* GET /api/v1/admin/workflows
*
* List all workflows across all workspaces with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkflow>
*/
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count } from 'drizzle-orm'
import { adminV1ListWorkflowsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowsAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(adminV1ListWorkflowsContract, request, {})
if (!parsed.success) return parsed.response
const { limit, offset } = parsed.data.query
try {
const [countResult, workflows] = await Promise.all([
db.select({ total: count() }).from(workflow),
db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.orderBy(workflow.name)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminWorkflow[] = workflows.map(toAdminWorkflow)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} workflows (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workflows', { error })
return internalErrorResponse('Failed to list workflows')
}
})
)
@@ -0,0 +1,192 @@
/**
* GET /api/v1/admin/workspaces/[id]/export
*
* Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip)
* - JSON: WorkspaceExportPayload
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import {
type FolderExportPayload,
parseWorkflowVariables,
type WorkflowExportState,
type WorkspaceExportPayload,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceExportAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ExportWorkspaceContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { format } = parsed.data.query
try {
const [workspaceData] = await db
.select({ id: workspace.id, name: workspace.name })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const workflows = await db
.select()
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
const folders = await db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
const workflowExports: Array<{
workflow: WorkspaceExportPayload['workflows'][number]['workflow']
state: WorkflowExportState
}> = []
for (const wf of workflows) {
try {
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
if (!normalizedData) {
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
continue
}
const variables = parseWorkflowVariables(wf.variables)
const state: WorkflowExportState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: wf.name,
description: wf.description ?? undefined,
exportedAt: new Date().toISOString(),
},
variables,
}
workflowExports.push({
workflow: {
id: wf.id,
name: wf.name,
description: wf.description,
workspaceId: wf.workspaceId,
folderId: wf.folderId,
},
state,
})
} catch (error) {
logger.error(`Failed to load workflow ${wf.id}:`, { error })
}
}
const folderExports: FolderExportPayload[] = folders.map((f) => ({
id: f.id,
name: f.name,
parentId: f.parentId,
}))
logger.info(
`Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders`
)
const auditExport = () =>
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.WORKSPACE_EXPORTED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: workspaceData.name,
description: `Admin API exported workspace "${workspaceData.name}"`,
metadata: {
format,
workflowCount: workflowExports.length,
folderCount: folderExports.length,
},
request,
})
if (format === 'json') {
auditExport()
const exportPayload: WorkspaceExportPayload = {
version: '1.0',
exportedAt: new Date().toISOString(),
workspace: {
id: workspaceData.id,
name: workspaceData.name,
},
workflows: workflowExports,
folders: folderExports,
}
return singleResponse(exportPayload)
}
const zipWorkflows = workflowExports.map((wf) => ({
workflow: {
id: wf.workflow.id,
name: wf.workflow.name,
description: wf.workflow.description ?? undefined,
folderId: wf.workflow.folderId,
},
state: wf.state,
variables: wf.state.variables,
}))
const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports)
const arrayBuffer = await zipBlob.arrayBuffer()
const sanitizedName = sanitizePathSegment(workspaceData.name)
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
auditExport()
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`,
'Content-Length': arrayBuffer.byteLength.toString(),
},
})
} catch (error) {
logger.error('Admin API: Failed to export workspace', { error, workspaceId })
return internalErrorResponse('Failed to export workspace')
}
})
)
@@ -0,0 +1,77 @@
/**
* GET /api/v1/admin/workspaces/[id]/folders
*
* List all folders in a workspace with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminFolder>
*/
import { db } from '@sim/db'
import { workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
import { type AdminFolder, createPaginationMeta, toAdminFolder } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceFoldersAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkspaceFoldersContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, folders] = await Promise.all([
db
.select({ total: count() })
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId)),
db
.select()
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId))
.orderBy(workflowFolder.sortOrder, workflowFolder.name)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminFolder[] = folders.map(toAdminFolder)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(
`Admin API: Listed ${data.length} folders in workspace ${workspaceId} (total: ${total})`
)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace folders', { error, workspaceId })
return internalErrorResponse('Failed to list folders')
}
})
)
@@ -0,0 +1,316 @@
/**
* POST /api/v1/admin/workspaces/[id]/import
*
* Import workflows into a workspace from a ZIP file or JSON.
*
* Content-Type:
* - application/zip or multipart/form-data (with 'file' field) for ZIP upload
* - application/json for JSON payload
*
* JSON Body:
* {
* workflows: Array<{
* content: string | object, // Workflow JSON
* name?: string, // Override name
* folderPath?: string[] // Folder path to create
* }>
* }
*
* Query Parameters:
* - createFolders: 'true' (default) or 'false'
* - rootFolderName: optional name for root import folder
*
* Response: WorkspaceImportResponse
*/
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import {
adminV1ImportWorkspaceContract,
adminV1WorkspaceImportBodySchema,
} from '@/lib/api/contracts/v1/admin'
import { parseJsonBody, parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
extractWorkflowName,
extractWorkflowsFromZip,
parseWorkflowJson,
} from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import type {
ImportResult,
WorkflowVariable,
WorkspaceImportRequest,
WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceImportAPI')
/**
* Body cap for admin bulk workflow imports, which can carry many serialized
* workflows and legitimately exceed the default contract-route limit.
*/
const ADMIN_IMPORT_MAX_BODY_BYTES = 100 * 1024 * 1024
interface RouteParams {
id: string
}
interface ParsedWorkflow {
content: string
name: string
folderPath: string[]
}
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { createFolders, rootFolderName } = parsed.data.query
try {
const workspaceData = await getWorkspaceWithOwner(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const contentType = request.headers.get('content-type') || ''
let workflowsToImport: ParsedWorkflow[] = []
if (contentType.includes('application/json')) {
const rawBody = await parseJsonBody(request, 'response', ADMIN_IMPORT_MAX_BODY_BYTES)
if (!rawBody.success) {
// Preserve the 413 for an oversized body; only invalid JSON maps to 400.
return rawBody.reason === 'too_large'
? rawBody.response
: badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
}
const validation = adminV1WorkspaceImportBodySchema.safeParse(rawBody.data)
if (!validation.success) {
return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
}
const body = validation.data as WorkspaceImportRequest
workflowsToImport = body.workflows.map((w) => ({
content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content),
name: w.name || 'Imported Workflow',
folderPath: w.folderPath || [],
}))
} else if (
contentType.includes('application/zip') ||
contentType.includes('multipart/form-data')
) {
let zipBuffer: ArrayBuffer
if (contentType.includes('multipart/form-data')) {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return badRequestResponse('No file provided in form data. Use field name "file".')
}
zipBuffer = await file.arrayBuffer()
} else {
zipBuffer = await request.arrayBuffer()
}
const blob = new Blob([zipBuffer], { type: 'application/zip' })
const file = new File([blob], 'import.zip', { type: 'application/zip' })
const { workflows } = await extractWorkflowsFromZip(file)
workflowsToImport = workflows
} else {
return badRequestResponse(
'Unsupported Content-Type. Use application/json or application/zip.'
)
}
if (workflowsToImport.length === 0) {
return badRequestResponse('No workflows found to import')
}
let rootFolderId: string | undefined
if (rootFolderName && createFolders) {
rootFolderId = generateId()
await db.insert(workflowFolder).values({
id: rootFolderId,
name: rootFolderName,
userId: workspaceData.ownerId,
workspaceId,
parentId: null,
createdAt: new Date(),
updatedAt: new Date(),
})
}
const folderMap = new Map<string, string>()
const results: ImportResult[] = []
for (const wf of workflowsToImport) {
const result = await importSingleWorkflow(
wf,
workspaceId,
workspaceData.ownerId,
createFolders,
rootFolderId,
folderMap
)
results.push(result)
if (result.success) {
logger.info(`Admin API: Imported workflow ${result.workflowId} (${result.name})`)
} else {
logger.warn(`Admin API: Failed to import workflow ${result.name}: ${result.error}`)
}
}
const imported = results.filter((r) => r.success).length
const failed = results.filter((r) => !r.success).length
logger.info(`Admin API: Import complete - ${imported} succeeded, ${failed} failed`)
const response: WorkspaceImportResponse = { imported, failed, results }
return NextResponse.json(response)
} catch (error) {
logger.error('Admin API: Failed to import into workspace', { error, workspaceId })
return internalErrorResponse('Failed to import workflows')
}
})
)
async function importSingleWorkflow(
wf: ParsedWorkflow,
workspaceId: string,
ownerId: string,
createFolders: boolean,
rootFolderId: string | undefined,
folderMap: Map<string, string>
): Promise<ImportResult> {
try {
const { data: workflowData, errors } = parseWorkflowJson(wf.content)
if (!workflowData || errors.length > 0) {
return {
workflowId: '',
name: wf.name,
success: false,
error: `Parse error: ${errors.join(', ')}`,
}
}
const workflowName = extractWorkflowName(wf.content, wf.name)
let targetFolderId: string | null = rootFolderId || null
if (createFolders && wf.folderPath.length > 0) {
let parentId = rootFolderId || null
for (let i = 0; i < wf.folderPath.length; i++) {
const pathSegment = wf.folderPath.slice(0, i + 1).join('/')
const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment
if (!folderMap.has(fullPath)) {
const folderId = generateId()
await db.insert(workflowFolder).values({
id: folderId,
name: wf.folderPath[i],
userId: ownerId,
workspaceId,
parentId,
createdAt: new Date(),
updatedAt: new Date(),
})
folderMap.set(fullPath, folderId)
parentId = folderId
} else {
parentId = folderMap.get(fullPath)!
}
}
const fullFolderPath = rootFolderId
? `root/${wf.folderPath.join('/')}`
: wf.folderPath.join('/')
targetFolderId = folderMap.get(fullFolderPath) || parentId
}
const workflowId = generateId()
const now = new Date()
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId)
await db.insert(workflow).values({
id: workflowId,
userId: ownerId,
workspaceId,
folderId: targetFolderId,
name: dedupedName,
description: workflowData.metadata?.description || 'Imported via Admin API',
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false,
runCount: 0,
variables: {},
})
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
if (!saveResult.success) {
await db.delete(workflow).where(eq(workflow.id, workflowId))
return {
workflowId: '',
name: dedupedName,
success: false,
error: `Failed to save state: ${saveResult.error}`,
}
}
if (workflowData.variables && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<string, WorkflowVariable> = {}
workflowData.variables.forEach((v) => {
const varId = v.id || generateId()
variablesRecord[varId] = {
id: varId,
name: v.name,
type: v.type || 'string',
value: v.value,
}
})
await db
.update(workflow)
.set({ variables: variablesRecord, updatedAt: new Date() })
.where(eq(workflow.id, workflowId))
}
return {
workflowId,
name: dedupedName,
success: true,
}
} catch (error) {
return {
workflowId: '',
name: wf.name,
success: false,
error: getErrorMessage(error, 'Unknown error'),
}
}
}
@@ -0,0 +1,320 @@
/**
* GET /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Get workspace member details.
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* PATCH /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Update member permissions.
*
* Body:
* - permissions: 'admin' | 'write' | 'read' - New permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember>
*
* DELETE /api/v1/admin/workspaces/[id]/members/[memberId]
*
* Remove member from workspace.
*
* Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import {
adminV1GetWorkspaceMemberContract,
adminV1RemoveWorkspaceMemberContract,
adminV1UpdateWorkspaceMemberContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { AdminWorkspaceMember } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceMemberDetailAPI')
interface RouteParams {
id: string
memberId: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, memberId } = parsed.data.params
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [memberData] = await db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!memberData) {
return notFoundResponse('Workspace member')
}
const data: AdminWorkspaceMember = {
id: memberData.id,
workspaceId,
userId: memberData.userId,
permissions: memberData.permissionType,
createdAt: memberData.createdAt.toISOString(),
updatedAt: memberData.updatedAt.toISOString(),
userName: memberData.userName,
userEmail: memberData.userEmail,
userImage: memberData.userImage,
}
logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to get workspace member')
}
})
)
export const PATCH = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1UpdateWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, memberId } = parsed.data.params
const { permissions: permissionLevel } = parsed.data.body
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingMember] = await db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
})
.from(permissions)
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingMember) {
return notFoundResponse('Workspace member')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (
workspaceBilling?.billedAccountUserId === existingMember.userId &&
permissionLevel !== 'admin'
) {
return badRequestResponse('Workspace billing account must retain admin permissions')
}
const now = new Date()
await db
.update(permissions)
.set({ permissionType: permissionLevel, updatedAt: now })
.where(eq(permissions.id, memberId))
const [userData] = await db
.select({ name: user.name, email: user.email, image: user.image })
.from(user)
.where(eq(user.id, existingMember.userId))
.limit(1)
const data: AdminWorkspaceMember = {
id: existingMember.id,
workspaceId,
userId: existingMember.userId,
permissions: permissionLevel,
createdAt: existingMember.createdAt.toISOString(),
updatedAt: now.toISOString(),
userName: userData?.name ?? '',
userEmail: userData?.email ?? '',
userImage: userData?.image ?? null,
}
logger.info(`Admin API: Updated member ${memberId} permissions to ${permissionLevel}`, {
workspaceId,
previousPermissions: existingMember.permissionType,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
metadata: {
memberId,
targetUserId: existingMember.userId,
previousPermissions: existingMember.permissionType,
permissions: permissionLevel,
},
request,
})
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to update workspace member')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1RemoveWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId, memberId } = parsed.data.params
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [existingMember] = await db
.select({
id: permissions.id,
userId: permissions.userId,
})
.from(permissions)
.where(
and(
eq(permissions.id, memberId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingMember) {
return notFoundResponse('Workspace member')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === existingMember.userId) {
return badRequestResponse(
'Cannot remove the workspace billing account. Please reassign billing first.'
)
}
await db.transaction(async (tx) => {
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: existingMember.userId,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: existingMember.userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx.delete(permissions).where(eq(permissions.id, memberId))
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId)
})
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
userId: existingMember.userId,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_REMOVED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: 'Admin API removed member from workspace',
metadata: { memberId, targetUserId: existingMember.userId },
request,
})
return singleResponse({
removed: true,
memberId,
userId: existingMember.userId,
workspaceId,
})
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId })
return internalErrorResponse('Failed to remove workspace member')
}
})
)
@@ -0,0 +1,402 @@
/**
* GET /api/v1/admin/workspaces/[id]/members
*
* List all members of a workspace with their permission details.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkspaceMember>
*
* POST /api/v1/admin/workspaces/[id]/members
*
* Add a user to a workspace with a specific permission level.
* If the user already has permissions, updates their permission level.
*
* Body:
* - userId: string - User ID to add
* - permissions: 'admin' | 'write' | 'read' - Permission level
*
* Response: AdminSingleResponse<AdminWorkspaceMember & { action: 'created' | 'updated' }>
*
* DELETE /api/v1/admin/workspaces/[id]/members
*
* Remove a user from a workspace.
*
* Query Parameters:
* - userId: string - User ID to remove
*
* Response: AdminSingleResponse<{ removed: true }>
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, count, eq } from 'drizzle-orm'
import {
adminV1CreateWorkspaceMemberContract,
adminV1DeleteWorkspaceMemberContract,
adminV1ListWorkspaceMembersContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
WorkspaceBillingAccountRemovalError,
} from '@/lib/workspaces/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
listResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminWorkspaceMember, createPaginationMeta } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceMembersAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkspaceMembersContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, membersData] = await Promise.all([
db
.select({ count: count() })
.from(permissions)
.where(
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
),
db
.select({
id: permissions.id,
userId: permissions.userId,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
userName: user.name,
userEmail: user.email,
userImage: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
)
.orderBy(permissions.createdAt)
.limit(limit)
.offset(offset),
])
const total = countResult[0].count
const data: AdminWorkspaceMember[] = membersData.map((m) => ({
id: m.id,
workspaceId,
userId: m.userId,
permissions: m.permissionType,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
userName: m.userName,
userEmail: m.userEmail,
userImage: m.userImage,
}))
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace members', { error, workspaceId })
return internalErrorResponse('Failed to list workspace members')
}
})
)
export const POST = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1CreateWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { userId, permissions: permissionLevel } = parsed.data.body
try {
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === userId && permissionLevel !== 'admin') {
return badRequestResponse('Workspace billing account must retain admin permissions')
}
const [userData] = await db
.select({ id: user.id, name: user.name, email: user.email, image: user.image })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
const [existingPermission] = await db
.select({
id: permissions.id,
permissionType: permissions.permissionType,
createdAt: permissions.createdAt,
updatedAt: permissions.updatedAt,
})
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (existingPermission) {
if (existingPermission.permissionType !== permissionLevel) {
const now = new Date()
await db
.update(permissions)
.set({ permissionType: permissionLevel, updatedAt: now })
.where(eq(permissions.id, existingPermission.id))
logger.info(`Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`, {
previousPermissions: existingPermission.permissionType,
newPermissions: permissionLevel,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
metadata: {
targetUserId: userId,
previousPermissions: existingPermission.permissionType,
permissions: permissionLevel,
},
request,
})
return singleResponse({
id: existingPermission.id,
workspaceId,
userId,
permissions: permissionLevel,
createdAt: existingPermission.createdAt.toISOString(),
updatedAt: now.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'updated' as const,
})
}
return singleResponse({
id: existingPermission.id,
workspaceId,
userId,
permissions: existingPermission.permissionType,
createdAt: existingPermission.createdAt.toISOString(),
updatedAt: existingPermission.updatedAt.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'already_member' as const,
})
}
const now = new Date()
const permissionId = generateId()
await db.insert(permissions).values({
id: permissionId,
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: permissionLevel,
createdAt: now,
updatedAt: now,
})
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
permissions: permissionLevel,
permissionId,
})
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_ADDED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: `Admin API added member to workspace with ${permissionLevel} permissions`,
metadata: { targetUserId: userId, permissions: permissionLevel },
request,
})
const [wsEnvRow] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
.limit(1)
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
if (wsEnvKeys.length > 0) {
await syncWorkspaceEnvCredentials({
workspaceId,
envKeys: wsEnvKeys,
actingUserId: userId,
})
}
return singleResponse({
id: permissionId,
workspaceId,
userId,
permissions: permissionLevel,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
userName: userData.name,
userEmail: userData.email,
userImage: userData.image,
action: 'created' as const,
})
} catch (error) {
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })
return internalErrorResponse('Failed to add workspace member')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1DeleteWorkspaceMemberContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { userId } = parsed.data.query
let targetUserId: string | undefined
try {
targetUserId = userId
const workspaceData = await getWorkspaceById(workspaceId)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [workspaceBilling] = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (workspaceBilling?.billedAccountUserId === userId) {
return badRequestResponse(
'Cannot remove the workspace billing account. Please reassign billing first.'
)
}
const [existingPermission] = await db
.select({ id: permissions.id })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!existingPermission) {
return notFoundResponse('Workspace member')
}
await db.transaction(async (tx) => {
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
tx,
workspaceId,
departingUserId: userId,
})
const workflowOwnershipReassignment =
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
tx,
workspaceIds: [workspaceId],
departingUserId: userId,
})
if (workflowOwnershipReassignment.unresolved.length > 0) {
throw new WorkspaceBillingAccountRemovalError()
}
await tx.delete(permissions).where(eq(permissions.id, existingPermission.id))
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
})
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: 'admin-api',
action: AuditAction.MEMBER_REMOVED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
description: 'Admin API removed member from workspace',
metadata: { targetUserId: userId },
request,
})
return singleResponse({ removed: true, userId, workspaceId })
} catch (error) {
if (error instanceof WorkspaceBillingAccountRemovalError) {
return badRequestResponse(error.message)
}
logger.error('Admin API: Failed to remove workspace member', {
error,
workspaceId,
userId: targetUserId,
})
return internalErrorResponse('Failed to remove workspace member')
}
})
)
@@ -0,0 +1,70 @@
/**
* GET /api/v1/admin/workspaces/[id]
*
* Get workspace details including workflow and folder counts.
*
* Response: AdminSingleResponse<AdminWorkspaceDetail>
*/
import { db } from '@sim/db'
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count, eq } from 'drizzle-orm'
import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminWorkspaceDetail, toAdminWorkspace } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceDetailAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1GetWorkspaceContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
try {
const [workspaceData] = await db
.select()
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [workflowCountResult, folderCountResult] = await Promise.all([
db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)),
db
.select({ count: count() })
.from(workflowFolder)
.where(eq(workflowFolder.workspaceId, workspaceId)),
])
const data: AdminWorkspaceDetail = {
...toAdminWorkspace(workspaceData),
workflowCount: workflowCountResult[0].count,
folderCount: folderCountResult[0].count,
}
logger.info(`Admin API: Retrieved workspace ${workspaceId}`)
return singleResponse(data)
} catch (error) {
logger.error('Admin API: Failed to get workspace', { error, workspaceId })
return internalErrorResponse('Failed to get workspace')
}
})
)
@@ -0,0 +1,141 @@
/**
* GET /api/v1/admin/workspaces/[id]/workflows
*
* List all workflows in a workspace with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkflow>
*
* DELETE /api/v1/admin/workspaces/[id]/workflows
*
* Delete all workflows in a workspace (clean slate for reimport).
*
* Response: { success: true, deleted: number }
*/
import { db } from '@sim/db'
import { workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import {
adminV1DeleteWorkspaceWorkflowsContract,
adminV1ListWorkspaceWorkflowsContract,
} from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceWorkflowsAPI')
interface RouteParams {
id: string
}
export const GET = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1ListWorkspaceWorkflowsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
const { limit, offset } = parsed.data.query
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const [countResult, workflows] = await Promise.all([
db
.select({ total: count() })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
workspaceId: workflow.workspaceId,
folderId: workflow.folderId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
.orderBy(workflow.name)
.limit(limit)
.offset(offset),
])
const total = countResult[0].total
const data: AdminWorkflow[] = workflows.map(toAdminWorkflow)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(
`Admin API: Listed ${data.length} workflows in workspace ${workspaceId} (total: ${total})`
)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspace workflows', { error, workspaceId })
return internalErrorResponse('Failed to list workflows')
}
})
)
export const DELETE = withRouteHandler(
withAdminAuthParams<RouteParams>(async (request, context) => {
const parsed = await parseRequest(adminV1DeleteWorkspaceWorkflowsContract, request, context)
if (!parsed.success) return parsed.response
const { id: workspaceId } = parsed.data.params
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const workflowsToDelete = await db
.select({ id: workflow.id })
.from(workflow)
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
if (workflowsToDelete.length === 0) {
return NextResponse.json({ success: true, deleted: 0 })
}
const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, {
requestId: `admin-workspace-${workspaceId}`,
})
logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`)
return NextResponse.json({ success: true, deleted: deletedCount })
} catch (error) {
logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId })
return internalErrorResponse('Failed to delete workflows')
}
})
)
@@ -0,0 +1,55 @@
/**
* GET /api/v1/admin/workspaces
*
* List all workspaces with pagination.
*
* Query Parameters:
* - limit: number (default: 50, max: 250)
* - offset: number (default: 0)
*
* Response: AdminListResponse<AdminWorkspace>
*/
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { count } from 'drizzle-orm'
import { adminV1ListWorkspacesContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminWorkspace,
createPaginationMeta,
toAdminWorkspace,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspacesAPI')
export const GET = withRouteHandler(
withAdminAuth(async (request) => {
const parsed = await parseRequest(adminV1ListWorkspacesContract, request, {})
if (!parsed.success) return parsed.response
const { limit, offset } = parsed.data.query
try {
const [countResult, workspaces] = await Promise.all([
db.select({ total: count() }).from(workspace),
db.select().from(workspace).orderBy(workspace.name).limit(limit).offset(offset),
])
const total = countResult[0].total
const data: AdminWorkspace[] = workspaces.map(toAdminWorkspace)
const pagination = createPaginationMeta(total, limit, offset)
logger.info(`Admin API: Listed ${data.length} workspaces (total: ${total})`)
return listResponse(data, pagination)
} catch (error) {
logger.error('Admin API: Failed to list workspaces', { error })
return internalErrorResponse('Failed to list workspaces')
}
})
)