chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

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')
}
})
)
@@ -0,0 +1,129 @@
/**
* @vitest-environment node
*
* Tests for GET /api/v1/audit-logs/[id] — verifies the lookup is constrained
* by the organization scope and 404s for rows outside it.
*/
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckRateLimit,
mockValidateEnterpriseAuditAccess,
mockBuildOrgScopeCondition,
mockGetOrgWorkspaceIds,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockValidateEnterpriseAuditAccess: vi.fn(),
mockBuildOrgScopeCondition: vi.fn(),
mockGetOrgWorkspaceIds: vi.fn(),
}))
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
createRateLimitResponse: vi.fn(),
}))
vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))
vi.mock('@/app/api/v1/audit-logs/query', () => ({
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
}))
vi.mock('@/app/api/v1/logs/meta', () => ({
getUserLimits: vi.fn().mockResolvedValue({}),
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
}))
import { GET } from '@/app/api/v1/audit-logs/[id]/route'
const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1', 'member-1']
const ORG_WORKSPACE_IDS = ['ws-org-1']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
const AUDIT_ROW = {
id: 'log-1',
workspaceId: 'ws-org-1',
actorId: 'member-1',
actorName: 'Member',
actorEmail: 'member@example.com',
action: 'workflow.created',
resourceType: 'workflow',
resourceId: 'wf-1',
resourceName: 'My Workflow',
description: 'Created workflow',
metadata: {},
ipAddress: '127.0.0.1',
userAgent: 'test',
createdAt: new Date('2026-01-01T00:00:00Z'),
}
function callRoute(id: string) {
const request = createMockRequest(
'GET',
undefined,
{},
`http://localhost:3000/api/v1/audit-logs/${id}`
)
return GET(request, { params: Promise.resolve({ id }) })
}
describe('GET /api/v1/audit-logs/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' })
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: true,
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
})
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
})
it('constrains the lookup with the org scope condition (includeDeparted)', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([AUDIT_ROW])
const response = await callRoute('log-1')
expect(response.status).toBe(200)
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith({
organizationId: ORG_ID,
orgWorkspaceIds: ORG_WORKSPACE_IDS,
orgMemberIds: MEMBER_IDS,
includeDeparted: true,
})
expect(dbChainMockFns.where).toHaveBeenCalledWith(
expect.objectContaining({
type: 'and',
conditions: expect.arrayContaining([SCOPE_SENTINEL]),
})
)
})
it('returns 404 when the row is outside the organization scope', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
const response = await callRoute('log-outside-org')
expect(response.status).toBe(404)
const body = await response.json()
expect(body.error).toBe('Audit log not found')
})
it('excludes ipAddress and userAgent from the response', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([AUDIT_ROW])
const response = await callRoute('log-1')
const body = await response.json()
expect(body.data.id).toBe('log-1')
expect(body.data.ipAddress).toBeUndefined()
expect(body.data.userAgent).toBeUndefined()
})
})
@@ -0,0 +1,88 @@
/**
* GET /api/v1/audit-logs/[id]
*
* Get a single audit log entry by ID, scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Scope is the organization boundary: logs within org-attached workspaces and
* org-level events (including those from departed members or system actions
* with null actorId).
*
* Response: { data: AuditLogEntry, limits: UserLimits }
*/
import { db } from '@sim/db'
import { auditLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogDetailAPI')
export const revalidate = 0
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1GetAuditLogContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid audit log ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { organizationId, orgMemberIds } = authResult.context
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted: true,
})
const [log] = await db
.select()
.from(auditLog)
.where(and(eq(auditLog.id, id), scopeCondition))
.limit(1)
if (!log) {
return NextResponse.json({ error: 'Audit log not found' }, { status: 404 })
}
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Audit log detail fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
+116
View File
@@ -0,0 +1,116 @@
/**
* Enterprise audit log authorization.
*
* Validates that the authenticated user is an admin/owner of an enterprise organization
* and returns the organization context needed for scoped queries.
*/
import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('V1AuditLogsAuth')
interface EnterpriseAuditContext {
organizationId: string
orgMemberIds: string[]
}
type AuthResult =
| { success: true; context: EnterpriseAuditContext }
| { success: false; response: NextResponse }
/**
* Validates enterprise audit log access for the given user.
*
* Checks:
* 1. User belongs to an organization
* 2. User has admin or owner role
* 3. Organization has an active enterprise subscription
*
* Returns the organization ID and all member user IDs on success,
* or an error response on failure.
*/
export async function validateEnterpriseAuditAccess(userId: string): Promise<AuthResult> {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(eq(member.userId, userId))
.limit(1)
if (!membership) {
return {
success: false,
response: NextResponse.json({ error: 'Not a member of any organization' }, { status: 403 }),
}
}
if (membership.role !== 'admin' && membership.role !== 'owner') {
return {
success: false,
response: NextResponse.json(
{ error: 'Organization admin or owner role required' },
{ status: 403 }
),
}
}
const billingStatus = await getEffectiveBillingStatus(userId)
if (billingStatus.billingBlocked) {
return {
success: false,
response: NextResponse.json(
{ error: 'Active enterprise subscription required' },
{ status: 403 }
),
}
}
const [orgSub, orgMembers] = await Promise.all([
db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, membership.organizationId),
eq(subscription.plan, 'enterprise'),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1),
db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, membership.organizationId)),
])
if (orgSub.length === 0) {
return {
success: false,
response: NextResponse.json(
{ error: 'Active enterprise subscription required' },
{ status: 403 }
),
}
}
const orgMemberIds = orgMembers.map((m) => m.userId)
logger.info('Enterprise audit access validated', {
userId,
organizationId: membership.organizationId,
memberCount: orgMemberIds.length,
})
return {
success: true,
context: {
organizationId: membership.organizationId,
orgMemberIds,
},
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Enterprise audit log response formatting.
*
* Defines the shape returned by the enterprise audit log API.
* Excludes `ipAddress` and `userAgent` for privacy.
*/
import type { auditLog } from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
type DbAuditLog = InferSelectModel<typeof auditLog>
export interface EnterpriseAuditLogEntry {
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
createdAt: string
}
export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry {
return {
id: log.id,
workspaceId: log.workspaceId,
actorId: log.actorId,
actorName: log.actorName,
actorEmail: log.actorEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
resourceName: log.resourceName,
description: log.description,
metadata: log.metadata,
createdAt: log.createdAt.toISOString(),
}
}
@@ -0,0 +1,172 @@
/**
* @vitest-environment node
*
* Tests for the enterprise audit-log tenant boundary. The global drizzle-orm
* mock returns structured operator objects, so these tests assert directly on
* the predicate tree.
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
const ORG_ID = 'org-1'
const MEMBER_IDS = ['user-1', 'user-2']
const WORKSPACE_IDS = ['ws-1', 'ws-2']
interface MockCondition {
type?: string
conditions?: MockCondition[]
column?: string
values?: string[]
left?: string
right?: string
strings?: string[]
}
function asCondition(value: unknown): MockCondition {
return value as MockCondition
}
/**
* Asserts the condition matches null-workspace rows tied to the organization
* via metadata or the organization resource itself.
*/
function expectOrgLevelCondition(condition: MockCondition, organizationId: string): void {
expect(condition.type).toBe('and')
const [nullCheck, orgLink] = condition.conditions!
expect(nullCheck).toMatchObject({ type: 'isNull', column: 'workspaceId' })
expect(orgLink.type).toBe('or')
const [metadataMatch, orgResourceMatch] = orgLink.conditions!
expect(metadataMatch.strings?.join('?')).toContain("->>'organizationId' =")
expect(metadataMatch.values).toContain(organizationId)
expect(orgResourceMatch.type).toBe('and')
expect(orgResourceMatch.conditions).toEqual([
expect.objectContaining({ type: 'eq', left: 'resourceType', right: 'organization' }),
expect.objectContaining({ type: 'eq', left: 'resourceId', right: organizationId }),
])
}
describe('buildOrgScopeCondition', () => {
it('never uses actor membership as a standalone boundary (default scope)', () => {
const condition = asCondition(
buildOrgScopeCondition({
organizationId: ORG_ID,
orgWorkspaceIds: WORKSPACE_IDS,
orgMemberIds: MEMBER_IDS,
includeDeparted: false,
})
)
expect(condition.type).toBe('and')
const [orgScope, actorFilter] = condition.conditions!
expect(orgScope.type).toBe('or')
const [workspaceScope, orgLevel] = orgScope.conditions!
expect(workspaceScope).toMatchObject({
type: 'inArray',
column: 'workspaceId',
values: WORKSPACE_IDS,
})
expectOrgLevelCondition(orgLevel, ORG_ID)
expect(actorFilter).toMatchObject({
type: 'or',
conditions: [
expect.objectContaining({ type: 'inArray', column: 'actorId', values: MEMBER_IDS }),
expect.objectContaining({ type: 'isNull', column: 'actorId' }),
],
})
})
it('omits the actor filter entirely when includeDeparted is true', () => {
const condition = asCondition(
buildOrgScopeCondition({
organizationId: ORG_ID,
orgWorkspaceIds: WORKSPACE_IDS,
orgMemberIds: MEMBER_IDS,
includeDeparted: true,
})
)
expect(condition.type).toBe('or')
const [workspaceScope, orgLevel] = condition.conditions!
expect(workspaceScope).toMatchObject({
type: 'inArray',
column: 'workspaceId',
values: WORKSPACE_IDS,
})
expectOrgLevelCondition(orgLevel, ORG_ID)
expect(JSON.stringify(condition)).not.toContain('actorId')
})
it('falls back to the org-level branch alone when the org has no workspaces', () => {
const condition = asCondition(
buildOrgScopeCondition({
organizationId: ORG_ID,
orgWorkspaceIds: [],
orgMemberIds: MEMBER_IDS,
includeDeparted: true,
})
)
expectOrgLevelCondition(condition, ORG_ID)
})
it('still applies the actor filter on top of the org scope with no workspaces', () => {
const condition = asCondition(
buildOrgScopeCondition({
organizationId: ORG_ID,
orgWorkspaceIds: [],
orgMemberIds: MEMBER_IDS,
includeDeparted: false,
})
)
expect(condition.type).toBe('and')
const [orgLevel, actorFilter] = condition.conditions!
expectOrgLevelCondition(orgLevel, ORG_ID)
expect(actorFilter).toMatchObject({
type: 'or',
conditions: [
expect.objectContaining({ type: 'inArray', column: 'actorId', values: MEMBER_IDS }),
expect.objectContaining({ type: 'isNull', column: 'actorId' }),
],
})
})
it('only matches system events when the org has no current members', () => {
const condition = asCondition(
buildOrgScopeCondition({
organizationId: ORG_ID,
orgWorkspaceIds: WORKSPACE_IDS,
orgMemberIds: [],
includeDeparted: false,
})
)
expect(condition.type).toBe('and')
const [, actorFilter] = condition.conditions!
expect(actorFilter).toMatchObject({ type: 'isNull', column: 'actorId' })
})
})
describe('getOrgWorkspaceIds', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('selects workspaces by organization ownership, not member ownership', async () => {
const ids = await getOrgWorkspaceIds(ORG_ID)
expect(ids).toEqual([])
expect(dbChainMockFns.where).toHaveBeenCalledWith(
expect.objectContaining({ type: 'eq', left: 'organizationId', right: ORG_ID })
)
})
})
+179
View File
@@ -0,0 +1,179 @@
import { AuditResourceType } from '@sim/audit'
import { db, dbReplica } from '@sim/db'
import { auditLog, workspace } from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
import { and, desc, eq, gte, ilike, inArray, isNull, lt, lte, or, type SQL, sql } from 'drizzle-orm'
type DbAuditLog = InferSelectModel<typeof auditLog>
interface CursorData {
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export interface AuditLogFilterParams {
action?: string
resourceType?: string
resourceId?: string
workspaceId?: string
actorId?: string
actorEmail?: string
search?: string
startDate?: string
endDate?: string
}
export function buildFilterConditions(params: AuditLogFilterParams): SQL<unknown>[] {
const conditions: SQL<unknown>[] = []
if (params.action) conditions.push(eq(auditLog.action, params.action))
if (params.resourceType) {
const types = params.resourceType.split(',').filter(Boolean)
if (types.length === 1) conditions.push(eq(auditLog.resourceType, types[0]))
else if (types.length > 1) conditions.push(inArray(auditLog.resourceType, types))
}
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
if (params.actorEmail) conditions.push(eq(auditLog.actorEmail, params.actorEmail))
if (params.search) {
const escaped = params.search.replace(/[%_\\]/g, '\\$&')
const searchTerm = `%${escaped}%`
conditions.push(
or(
ilike(auditLog.action, searchTerm),
ilike(auditLog.actorEmail, searchTerm),
ilike(auditLog.actorName, searchTerm),
ilike(auditLog.resourceName, searchTerm),
ilike(auditLog.description, searchTerm)
)!
)
}
if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate)))
if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate)))
return conditions
}
/**
* Returns the IDs of all workspaces attached to the organization.
*/
export async function getOrgWorkspaceIds(organizationId: string): Promise<string[]> {
const rows = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.organizationId, organizationId))
return rows.map((row) => row.id)
}
export interface OrgScopeParams {
organizationId: string
orgWorkspaceIds: string[]
orgMemberIds: string[]
includeDeparted: boolean
}
/**
* Builds the tenant-boundary predicate for organization audit log access:
* rows in org-attached workspaces, plus org-level rows (`workspace_id IS
* NULL`) tied to the org via `metadata.organizationId` or the organization
* resource itself. Actor membership is never a standalone boundary — when
* `includeDeparted` is false it only narrows the org scope to current members
* and system events (null actor).
*/
export function buildOrgScopeCondition(params: OrgScopeParams): SQL<unknown> {
const { organizationId, orgWorkspaceIds, orgMemberIds, includeDeparted } = params
const orgLevelCondition = and(
isNull(auditLog.workspaceId),
or(
sql`${auditLog.metadata}->>'organizationId' = ${organizationId}`,
and(
eq(auditLog.resourceType, AuditResourceType.ORGANIZATION),
eq(auditLog.resourceId, organizationId)
)
)
)!
const orgScope =
orgWorkspaceIds.length > 0
? or(inArray(auditLog.workspaceId, orgWorkspaceIds), orgLevelCondition)!
: orgLevelCondition
if (includeDeparted) {
return orgScope
}
const currentActorCondition =
orgMemberIds.length > 0
? or(inArray(auditLog.actorId, orgMemberIds), isNull(auditLog.actorId))!
: isNull(auditLog.actorId)
return and(orgScope, currentActorCondition)!
}
function buildCursorCondition(cursor: string): SQL<unknown> | null {
const cursorData = decodeCursor(cursor)
if (!cursorData?.createdAt || !cursorData.id) return null
const cursorDate = new Date(cursorData.createdAt)
if (Number.isNaN(cursorDate.getTime())) return null
return or(
lt(auditLog.createdAt, cursorDate),
and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id))
)!
}
interface CursorPaginatedResult {
data: DbAuditLog[]
nextCursor?: string
}
export async function queryAuditLogs(
conditions: SQL<unknown>[],
limit: number,
cursor?: string
): Promise<CursorPaginatedResult> {
const allConditions = [...conditions]
if (cursor) {
const cursorCondition = buildCursorCondition(cursor)
if (cursorCondition) allConditions.push(cursorCondition)
}
const rows = await dbReplica
.select()
.from(auditLog)
.where(allConditions.length > 0 ? and(...allConditions) : undefined)
.orderBy(desc(auditLog.createdAt), desc(auditLog.id))
.limit(limit + 1)
const hasMore = rows.length > limit
const data = rows.slice(0, limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const last = data[data.length - 1]
nextCursor = encodeCursor({
createdAt: last.createdAt.toISOString(),
id: last.id,
})
}
return { data, nextCursor }
}
@@ -0,0 +1,130 @@
/**
* @vitest-environment node
*
* Tests for GET /api/v1/audit-logs — verifies filters are validated against
* the caller's organization and the scope is built from the org context.
*/
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckRateLimit,
mockValidateEnterpriseAuditAccess,
mockBuildOrgScopeCondition,
mockGetOrgWorkspaceIds,
mockQueryAuditLogs,
mockBuildFilterConditions,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockValidateEnterpriseAuditAccess: vi.fn(),
mockBuildOrgScopeCondition: vi.fn(),
mockGetOrgWorkspaceIds: vi.fn(),
mockQueryAuditLogs: vi.fn(),
mockBuildFilterConditions: vi.fn(),
}))
vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
createRateLimitResponse: vi.fn(),
}))
vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))
vi.mock('@/app/api/v1/audit-logs/query', () => ({
buildFilterConditions: mockBuildFilterConditions,
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
queryAuditLogs: mockQueryAuditLogs,
}))
vi.mock('@/app/api/v1/logs/meta', () => ({
getUserLimits: vi.fn().mockResolvedValue({}),
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
}))
import { GET } from '@/app/api/v1/audit-logs/route'
const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1', 'member-1']
const ORG_WORKSPACE_IDS = ['ws-org-1', 'ws-org-2']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
function makeRequest(query: string) {
return createMockRequest('GET', undefined, {}, `http://localhost:3000/api/v1/audit-logs${query}`)
}
describe('GET /api/v1/audit-logs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' })
mockValidateEnterpriseAuditAccess.mockResolvedValue({
success: true,
context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS },
})
mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS)
mockBuildOrgScopeCondition.mockReturnValue(SCOPE_SENTINEL)
mockBuildFilterConditions.mockReturnValue([])
mockQueryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
})
it('rejects an actorId that is not a current org member', async () => {
const response = await GET(makeRequest('?actorId=outsider-1'))
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('actorId is not a member of your organization')
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
it('rejects a workspaceId that does not belong to the organization', async () => {
const response = await GET(makeRequest('?workspaceId=ws-other-org'))
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('workspaceId does not belong to your organization')
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
it('accepts a workspaceId that belongs to the organization', async () => {
const response = await GET(makeRequest('?workspaceId=ws-org-1'))
expect(response.status).toBe(200)
expect(mockQueryAuditLogs).toHaveBeenCalled()
})
it('builds the scope from the organization context, never from actors alone', async () => {
const response = await GET(makeRequest('?actorId=member-1'))
expect(response.status).toBe(200)
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith({
organizationId: ORG_ID,
orgWorkspaceIds: ORG_WORKSPACE_IDS,
orgMemberIds: MEMBER_IDS,
includeDeparted: false,
})
const [conditions] = mockQueryAuditLogs.mock.calls[0]
expect(conditions[0]).toBe(SCOPE_SENTINEL)
})
it('passes includeDeparted through to the scope builder', async () => {
const response = await GET(makeRequest('?includeDeparted=true'))
expect(response.status).toBe(200)
expect(mockBuildOrgScopeCondition).toHaveBeenCalledWith(
expect.objectContaining({ includeDeparted: true })
)
})
it('returns the auth failure response when enterprise access is denied', async () => {
const denied = new Response(JSON.stringify({ error: 'nope' }), { status: 403 })
mockValidateEnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied })
const response = await GET(makeRequest(''))
expect(response.status).toBe(403)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
})
+131
View File
@@ -0,0 +1,131 @@
/**
* GET /api/v1/audit-logs
*
* List audit logs scoped to the authenticated user's organization.
* Requires enterprise subscription and org admin/owner role.
*
* Query Parameters:
* - action: string (optional) - Filter by action (e.g., "workflow.created")
* - resourceType: string (optional) - Filter by resource type(s), comma-separated (e.g., "workflow,api_key")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID (must be an org member)
* - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate
* - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate
* - includeDeparted: boolean (optional, default: false) - Include logs from departed members
* - limit: number (optional, default: 50, max: 100)
* - cursor: string (optional) - Opaque cursor for pagination
*
* Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits }
*/
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
const logger = createLogger('V1AuditLogsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'audit-logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const authResult = await validateEnterpriseAuditAccess(userId)
if (!authResult.success) {
return authResult.response
}
const { organizationId, orgMemberIds } = authResult.context
const parsed = await parseRequest(
v1ListAuditLogsContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{
error: getValidationErrorMessage(error, 'Invalid parameters'),
details: error.issues,
},
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.query
if (params.actorId && !orgMemberIds.includes(params.actorId)) {
return NextResponse.json(
{ error: 'actorId is not a member of your organization' },
{ status: 400 }
)
}
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
if (params.workspaceId && !orgWorkspaceIds.includes(params.workspaceId)) {
return NextResponse.json(
{ error: 'workspaceId does not belong to your organization' },
{ status: 400 }
)
}
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted: params.includeDeparted,
})
const filterConditions = buildFilterConditions({
action: params.action,
resourceType: params.resourceType,
resourceId: params.resourceId,
workspaceId: params.workspaceId,
actorId: params.actorId,
startDate: params.startDate,
endDate: params.endDate,
})
const { data, nextCursor } = await queryAuditLogs(
[scopeCondition, ...filterConditions],
params.limit,
params.cursor
)
const formattedLogs = data.map(formatAuditLogEntry)
const limits = await getUserLimits(userId)
const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Audit logs fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+61
View File
@@ -0,0 +1,61 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
const logger = createLogger('V1Auth')
export interface AuthResult {
authenticated: boolean
userId?: string
workspaceId?: string
keyType?: 'personal' | 'workspace'
error?: string
}
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
if (isAuthDisabled) {
return {
authenticated: true,
userId: ANONYMOUS_USER_ID,
keyType: 'personal',
}
}
const apiKey = request.headers.get('x-api-key')
if (!apiKey) {
return {
authenticated: false,
error: 'API key required',
}
}
try {
const result = await authenticateApiKeyFromHeader(apiKey)
if (!result.success) {
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
return {
authenticated: false,
error: result.error || 'Invalid API key',
}
}
await updateApiKeyLastUsed(result.keyId!)
return {
authenticated: true,
userId: result.userId!,
workspaceId: result.workspaceId,
keyType: result.keyType,
}
} catch (error) {
logger.error('API key authentication error', { error })
return {
authenticated: false,
error: 'Authentication failed',
}
}
}
@@ -0,0 +1,26 @@
/**
* Tests for the deprecated v1 copilot chat API route
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { POST } from '@/app/api/v1/copilot/chat/route'
const URL = 'http://localhost:3000/api/v1/copilot/chat'
describe('Deprecated v1 copilot chat route', () => {
it('POST returns 410 with a success:false error body', async () => {
const request = new NextRequest(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'sk-test' },
body: JSON.stringify({ message: 'hello' }),
})
const response = await POST(request)
expect(response.status).toBe(410)
const body = (await response.json()) as { success?: boolean; error?: string }
expect(body.success).toBe(false)
expect(body.error).toContain('deprecated')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
/**
* POST /api/v1/copilot/chat
*
* Deprecated: the v1 headless copilot chat API has been removed. The endpoint
* returns 410 Gone for all callers.
*/
export const POST = withRouteHandler(async () =>
NextResponse.json(
{
success: false,
error: 'The v1 copilot chat API has been deprecated and is no longer available.',
},
{ status: 410, headers: { 'Cache-Control': 'no-store' } }
)
)
+144
View File
@@ -0,0 +1,144 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { v1DeleteFileContract, v1DownloadFileContract } from '@/lib/api/contracts/v1/files'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1FileDetailAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface FileRouteParams {
params: Promise<{ fileId: string }>
}
/** GET /api/v1/files/[fileId] — Download file content. */
export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'file-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DownloadFileContract, request, context)
if (!parsed.success) return parsed.response
const { fileId } = parsed.data.params
const { workspaceId } = parsed.data.query
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
if (accessError) return accessError
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const buffer = await fetchWorkspaceFileBuffer(fileRecord)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FILE_DOWNLOADED,
resourceType: AuditResourceType.FILE,
resourceId: fileRecord.id,
resourceName: fileRecord.name,
description: `Downloaded file "${fileRecord.name}" via API`,
metadata: {
fileId: fileRecord.id,
fileName: fileRecord.name,
bytes: buffer.length,
source: 'api_v1',
},
request,
})
captureServerEvent(
userId,
'file_downloaded',
{ workspace_id: workspaceId, is_bulk: false, file_count: 1 },
{ groups: { workspace: workspaceId } }
)
return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': fileRecord.type || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`,
'Content-Length': String(buffer.length),
'X-File-Id': fileRecord.id,
'X-File-Name': encodeURIComponent(fileRecord.name),
'X-Uploaded-At':
fileRecord.uploadedAt instanceof Date
? fileRecord.uploadedAt.toISOString()
: String(fileRecord.uploadedAt),
},
})
} catch (error) {
logger.error(`[${requestId}] Error downloading file:`, error)
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
}
})
/** DELETE /api/v1/files/[fileId] — Archive a file. */
export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'file-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DeleteFileContract, request, context)
if (!parsed.success) return parsed.response
const { fileId } = parsed.data.params
const { workspaceId } = parsed.data.query
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
if (accessError) return accessError
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
if (!fileRecord) {
return NextResponse.json({ error: 'File not found' }, { status: 404 })
}
const result = await performDeleteWorkspaceFileItems({
workspaceId,
userId,
fileIds: [fileId],
})
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 500 })
}
logger.info(
`[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}`
)
return NextResponse.json({
success: true,
data: {
message: 'File archived successfully',
},
})
} catch (error) {
logger.error(`[${requestId}] Error deleting file:`, error)
return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 })
}
})
+205
View File
@@ -0,0 +1,205 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListFilesContract, v1UploadFileFormFieldsSchema } from '@/lib/api/contracts/v1/files'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import {
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
readFileToBufferWithLimit,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
FileConflictError,
getWorkspaceFile,
listWorkspaceFiles,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1FilesAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = 100 * 1024 * 1024
/** GET /api/v1/files — List all files in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'files')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1ListFilesContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId } = parsed.data.query
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
if (accessError) return accessError
const files = await listWorkspaceFiles(workspaceId)
return NextResponse.json({
success: true,
data: {
files: files.map((f) => ({
id: f.id,
name: f.name,
size: f.size,
type: f.type,
key: f.key,
uploadedBy: f.uploadedBy,
uploadedAt:
f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt),
})),
totalCount: files.length,
},
})
} catch (error) {
logger.error(`[${requestId}] Error listing files:`, error)
return NextResponse.json({ error: 'Failed to list files' }, { status: 500 })
}
})
/** POST /api/v1/files — Upload a file to a workspace. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'files')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
let formData: FormData
try {
formData = await readFormDataWithLimit(request, {
maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
label: 'workspace file upload body',
})
} catch (error) {
if (isPayloadSizeLimitError(error)) {
return NextResponse.json({ error: error.message }, { status: 413 })
}
return NextResponse.json(
{ error: 'Request body must be valid multipart form data' },
{ status: 400 }
)
}
const rawFile = formData.get('file')
const file = rawFile instanceof File ? rawFile : null
const rawWorkspaceId = formData.get('workspaceId')
const formFieldsResult = v1UploadFileFormFieldsSchema.safeParse({
workspaceId: typeof rawWorkspaceId === 'string' ? rawWorkspaceId : '',
})
if (!formFieldsResult.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(formFieldsResult.error, 'Invalid form data') },
{ status: 400 }
)
}
const { workspaceId } = formFieldsResult.data
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
if (!file) {
return NextResponse.json({ error: 'file form field is required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{
error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`,
},
{ status: 413 }
)
}
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
if (accessError) return accessError
const buffer = await readFileToBufferWithLimit(file, {
maxBytes: MAX_FILE_SIZE,
label: 'workspace upload file',
})
const userFile = await uploadWorkspaceFile(
workspaceId,
userId,
buffer,
file.name,
file.type || 'application/octet-stream'
)
logger.info(`[${requestId}] Uploaded file: ${file.name} to workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: file.name,
description: `Uploaded file "${file.name}" via API`,
metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' },
request,
})
const fileRecord = await getWorkspaceFile(workspaceId, userFile.id)
const uploadedAt =
fileRecord?.uploadedAt instanceof Date
? fileRecord.uploadedAt.toISOString()
: fileRecord?.uploadedAt
? String(fileRecord.uploadedAt)
: new Date().toISOString()
return NextResponse.json({
success: true,
data: {
file: {
id: userFile.id,
name: userFile.name,
size: userFile.size,
type: userFile.type,
key: userFile.key,
uploadedBy: userId,
uploadedAt,
},
message: 'File uploaded successfully',
},
})
} catch (error) {
if (isPayloadSizeLimitError(error)) {
return NextResponse.json({ error: error.message }, { status: 413 })
}
const errorMessage = getErrorMessage(error, 'Failed to upload file')
const isDuplicate =
error instanceof FileConflictError || errorMessage.includes('already exists')
if (isDuplicate) {
return NextResponse.json({ error: errorMessage }, { status: 409 })
}
logger.error(`[${requestId}] Error uploading file:`, error)
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
})
@@ -0,0 +1,175 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { document, knowledgeConnector } from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1DeleteKnowledgeDocumentContract,
v1GetKnowledgeDocumentContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteDocument } from '@/lib/knowledge/documents/service'
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
import { authenticateRequest } from '@/app/api/v1/middleware'
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface DocumentDetailRouteParams {
params: Promise<{ id: string; documentId: string }>
}
/** GET /api/v1/knowledge/[id]/documents/[documentId] — Get document details. */
export const GET = withRouteHandler(
async (request: NextRequest, context: DocumentDetailRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, documentId } = parsed.data.params
const result = await resolveKnowledgeBase(
knowledgeBaseId,
parsed.data.query.workspaceId,
userId,
rateLimit
)
if (result instanceof NextResponse) return result
const docs = await db
.select({
id: document.id,
knowledgeBaseId: document.knowledgeBaseId,
filename: document.filename,
fileSize: document.fileSize,
mimeType: document.mimeType,
processingStatus: document.processingStatus,
processingError: document.processingError,
processingStartedAt: document.processingStartedAt,
processingCompletedAt: document.processingCompletedAt,
chunkCount: document.chunkCount,
tokenCount: document.tokenCount,
characterCount: document.characterCount,
enabled: document.enabled,
uploadedAt: document.uploadedAt,
connectorId: document.connectorId,
connectorType: knowledgeConnector.connectorType,
sourceUrl: document.sourceUrl,
})
.from(document)
.leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id))
.where(
and(
eq(document.id, documentId),
eq(document.knowledgeBaseId, knowledgeBaseId),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.limit(1)
if (docs.length === 0) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
const doc = docs[0]
return NextResponse.json({
success: true,
data: {
document: {
id: doc.id,
knowledgeBaseId: doc.knowledgeBaseId,
filename: doc.filename,
fileSize: doc.fileSize,
mimeType: doc.mimeType,
processingStatus: doc.processingStatus,
processingError: doc.processingError,
processingStartedAt: serializeDate(doc.processingStartedAt),
processingCompletedAt: serializeDate(doc.processingCompletedAt),
chunkCount: doc.chunkCount,
tokenCount: doc.tokenCount,
characterCount: doc.characterCount,
enabled: doc.enabled,
connectorId: doc.connectorId,
connectorType: doc.connectorType,
sourceUrl: doc.sourceUrl,
createdAt: serializeDate(doc.uploadedAt),
},
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to get document')
}
}
)
/** DELETE /api/v1/knowledge/[id]/documents/[documentId] — Delete a document. */
export const DELETE = withRouteHandler(
async (request: NextRequest, context: DocumentDetailRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId, documentId } = parsed.data.params
const result = await resolveKnowledgeBase(
knowledgeBaseId,
parsed.data.query.workspaceId,
userId,
rateLimit,
'write'
)
if (result instanceof NextResponse) return result
const docs = await db
.select({ id: document.id, filename: document.filename })
.from(document)
.where(
and(
eq(document.id, documentId),
eq(document.knowledgeBaseId, knowledgeBaseId),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
.limit(1)
if (docs.length === 0) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
await deleteDocument(documentId, requestId)
recordAudit({
workspaceId: parsed.data.query.workspaceId,
actorId: userId,
action: AuditAction.DOCUMENT_DELETED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: docs[0].filename,
description: `Deleted document "${docs[0].filename}" from knowledge base via API`,
metadata: { knowledgeBaseId },
request,
})
return NextResponse.json({
success: true,
data: {
message: 'Document deleted successfully',
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to delete document')
}
}
)
@@ -0,0 +1,168 @@
/**
* Tests for the v1 knowledge document upload route's bounded multipart read.
*
* @vitest-environment node
*/
import { getErrorMessage } from '@sim/utils/errors'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAuthenticateRequest,
mockResolveKnowledgeBase,
mockCheckActorUsageLimits,
mockUploadWorkspaceFile,
mockCreateSingleDocument,
mockProcessDocumentsWithQueue,
mockValidateFileType,
} = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockResolveKnowledgeBase: vi.fn(),
mockCheckActorUsageLimits: vi.fn(),
mockUploadWorkspaceFile: vi.fn(),
mockCreateSingleDocument: vi.fn(),
mockProcessDocumentsWithQueue: vi.fn(),
mockValidateFileType: vi.fn(),
}))
vi.mock('@/app/api/v1/middleware', () => ({
authenticateRequest: mockAuthenticateRequest,
}))
vi.mock('@/app/api/v1/knowledge/utils', () => ({
resolveKnowledgeBase: mockResolveKnowledgeBase,
serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date),
handleError: (_requestId: string, error: unknown) =>
new Response(JSON.stringify({ error: getErrorMessage(error, 'error') }), {
status: 500,
}),
}))
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: mockCheckActorUsageLimits,
}))
vi.mock('@/lib/uploads/contexts/workspace', () => ({
uploadWorkspaceFile: mockUploadWorkspaceFile,
}))
vi.mock('@/lib/uploads/utils/validation', () => ({
validateFileType: mockValidateFileType,
}))
vi.mock('@/lib/knowledge/documents/service', () => ({
createSingleDocument: mockCreateSingleDocument,
getDocuments: vi.fn(),
processDocumentsWithQueue: mockProcessDocumentsWithQueue,
}))
import { POST } from '@/app/api/v1/knowledge/[id]/documents/route'
const routeContext = { params: Promise.resolve({ id: 'kb-1' }) }
function buildFormData(file: File, workspaceId = 'ws-1'): FormData {
const formData = new FormData()
formData.append('workspaceId', workspaceId)
formData.append('file', file)
return formData
}
/**
* Builds a pull-based stream that emits fixed-size chunks on demand, so the
* size-capped reader's `reader.cancel()` simply stops future `pull` calls
* instead of racing an external (e.g. undici FormData) chunk producer.
*/
function makeChunkedOverLimitBody(
chunkBytes: number,
chunkCount: number
): ReadableStream<Uint8Array> {
let emitted = 0
return new ReadableStream<Uint8Array>({
pull(controller) {
if (emitted >= chunkCount) {
controller.close()
return
}
emitted++
controller.enqueue(new Uint8Array(chunkBytes))
},
})
}
describe('v1 knowledge document upload route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthenticateRequest.mockResolvedValue({
requestId: 'req-1',
userId: 'user-1',
rateLimit: {},
})
mockResolveKnowledgeBase.mockResolvedValue({ kb: { id: 'kb-1', workspaceId: 'ws-1' } })
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
mockValidateFileType.mockReturnValue(null)
mockUploadWorkspaceFile.mockResolvedValue({
url: 'https://example.com/file.txt',
})
mockCreateSingleDocument.mockResolvedValue({
id: 'doc-1',
filename: 'file.txt',
fileSize: 100,
mimeType: 'text/plain',
enabled: true,
uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
})
mockProcessDocumentsWithQueue.mockResolvedValue(undefined)
})
it('rejects a declared content-length above the limit before reading the body', async () => {
const formData = buildFormData(new File(['x'.repeat(10)], 'file.txt', { type: 'text/plain' }))
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
method: 'POST',
headers: { 'content-length': String(200 * 1024 * 1024) },
body: formData,
})
const response = await POST(req, routeContext)
const data = await response.json()
expect(response.status).toBe(413)
expect(data.error).toContain('exceeds maximum size')
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('rejects a chunked body without content-length once the streamed size trips the cap', async () => {
const body = makeChunkedOverLimitBody(1024 * 1024, 200)
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
method: 'POST',
body,
// @ts-expect-error - duplex is required by undici for streamed bodies but missing from NextRequestInit types
duplex: 'half',
})
expect(req.headers.get('content-length')).toBeNull()
const response = await POST(req, routeContext)
const data = await response.json()
expect(response.status).toBe(413)
expect(data.error).toContain('exceeds maximum size')
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
})
it('uploads a normal, well-under-limit document successfully', async () => {
const file = new File(['hello world'], 'file.txt', { type: 'text/plain' })
const formData = buildFormData(file)
const req = new NextRequest('http://localhost:3000/api/v1/knowledge/kb-1/documents', {
method: 'POST',
headers: { 'content-length': '1024' },
body: formData,
})
const response = await POST(req, routeContext)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockUploadWorkspaceFile).toHaveBeenCalledTimes(1)
expect(mockCreateSingleDocument).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,236 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1ListKnowledgeDocumentsContract,
v1UploadKnowledgeDocumentContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import {
isPayloadSizeLimitError,
MAX_MULTIPART_OVERHEAD_BYTES,
readFormDataWithLimit,
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createSingleDocument,
type DocumentData,
getDocuments,
processDocumentsWithQueue,
} from '@/lib/knowledge/documents/service'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
import { authenticateRequest } from '@/app/api/v1/middleware'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
interface DocumentsRouteParams {
params: Promise<{ id: string }>
}
/** GET /api/v1/knowledge/[id]/documents — List documents in a knowledge base. */
export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context)
if (!parsed.success) return parsed.response
const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } =
parsed.data.query
const { id: knowledgeBaseId } = parsed.data.params
const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit)
if (result instanceof NextResponse) return result
const documentsResult = await getDocuments(
knowledgeBaseId,
{
enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter,
search,
limit,
offset,
sortBy: sortBy as DocumentSortField,
sortOrder: sortOrder as SortOrder,
},
requestId
)
return NextResponse.json({
success: true,
data: {
documents: documentsResult.documents.map((doc) => ({
id: doc.id,
knowledgeBaseId,
filename: doc.filename,
fileSize: doc.fileSize,
mimeType: doc.mimeType,
processingStatus: doc.processingStatus,
chunkCount: doc.chunkCount,
tokenCount: doc.tokenCount,
characterCount: doc.characterCount,
enabled: doc.enabled,
createdAt: serializeDate(doc.uploadedAt),
})),
pagination: documentsResult.pagination,
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to list documents')
}
})
/** POST /api/v1/knowledge/[id]/documents — Upload a document to a knowledge base. */
export const POST = withRouteHandler(
async (request: NextRequest, context: DocumentsRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context)
if (!parsed.success) return parsed.response
const { id: knowledgeBaseId } = parsed.data.params
let formData: FormData
try {
formData = await readFormDataWithLimit(request, {
maxBytes: MAX_FILE_SIZE + MAX_MULTIPART_OVERHEAD_BYTES,
label: 'knowledge document upload body',
})
} catch (error) {
if (isPayloadSizeLimitError(error)) {
return NextResponse.json({ error: error.message }, { status: 413 })
}
return NextResponse.json(
{ error: 'Request body must be valid multipart form data' },
{ status: 400 }
)
}
const rawFile = formData.get('file')
const file = rawFile instanceof File ? rawFile : null
const rawWorkspaceId = formData.get('workspaceId')
const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null
if (!workspaceId) {
return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 })
}
if (!file) {
return NextResponse.json({ error: 'file form field is required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{
error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`,
},
{ status: 413 }
)
}
const fileTypeError = validateFileType(file.name, file.type || '')
if (fileTypeError) {
return NextResponse.json({ error: fileTypeError.message }, { status: 415 })
}
const result = await resolveKnowledgeBase(
knowledgeBaseId,
workspaceId,
userId,
rateLimit,
'write'
)
if (result instanceof NextResponse) return result
// Fast usage gate before the storage write + indexing (the async backstop
// in processDocumentAsync still covers non-HTTP paths).
const usage = await checkActorUsageLimits(userId, workspaceId)
if (usage.isExceeded) {
return NextResponse.json(
{
error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
},
{ status: 402 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const contentType = file.type || 'application/octet-stream'
const uploadedFile = await uploadWorkspaceFile(
workspaceId,
userId,
buffer,
file.name,
contentType
)
const newDocument = await createSingleDocument(
{
filename: file.name,
fileUrl: uploadedFile.url,
fileSize: file.size,
mimeType: contentType,
},
knowledgeBaseId,
requestId,
userId
)
const documentData: DocumentData = {
documentId: newDocument.id,
filename: file.name,
fileUrl: uploadedFile.url,
fileSize: file.size,
mimeType: contentType,
}
processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => {
// Processing errors are logged internally
})
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: newDocument.id,
resourceName: file.name,
description: `Uploaded document "${file.name}" to knowledge base via API`,
metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType },
request,
})
return NextResponse.json({
success: true,
data: {
document: {
id: newDocument.id,
knowledgeBaseId,
filename: newDocument.filename,
fileSize: newDocument.fileSize,
mimeType: newDocument.mimeType,
processingStatus: 'pending',
chunkCount: 0,
tokenCount: 0,
characterCount: 0,
enabled: newDocument.enabled,
createdAt: serializeDate(newDocument.uploadedAt),
},
message: 'Document uploaded successfully. Processing will begin shortly.',
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to upload document')
}
}
)
+145
View File
@@ -0,0 +1,145 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1DeleteKnowledgeBaseContract,
v1GetKnowledgeBaseContract,
v1UpdateKnowledgeBaseContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service'
import {
formatKnowledgeBase,
handleError,
resolveKnowledgeBase,
} from '@/app/api/v1/knowledge/utils'
import { authenticateRequest } from '@/app/api/v1/middleware'
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface KnowledgeRouteParams {
params: Promise<{ id: string }>
}
/** GET /api/v1/knowledge/[id] — Get knowledge base details. */
export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const result = await resolveKnowledgeBase(id, parsed.data.query.workspaceId, userId, rateLimit)
if (result instanceof NextResponse) return result
return NextResponse.json({
success: true,
data: {
knowledgeBase: formatKnowledgeBase(result.kb),
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to get knowledge base')
}
})
/** PUT /api/v1/knowledge/[id] — Update a knowledge base. */
export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const { workspaceId, name, description, chunkingConfig } = parsed.data.body
const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write')
if (result instanceof NextResponse) return result
const updates: {
name?: string
description?: string
chunkingConfig?: { maxSize: number; minSize: number; overlap: number }
} = {}
if (name !== undefined) updates.name = name
if (description !== undefined) updates.description = description
if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig
const updatedKb = await updateKnowledgeBase(id, updates, requestId)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.KNOWLEDGE_BASE_UPDATED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: updatedKb.name,
description: `Updated knowledge base "${updatedKb.name}" via API`,
metadata: { updatedFields: Object.keys(updates) },
request,
})
return NextResponse.json({
success: true,
data: {
knowledgeBase: formatKnowledgeBase(updatedKb),
message: 'Knowledge base updated successfully',
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to update knowledge base')
}
})
/** DELETE /api/v1/knowledge/[id] — Delete a knowledge base. */
export const DELETE = withRouteHandler(
async (request: NextRequest, context: KnowledgeRouteParams) => {
const auth = await authenticateRequest(request, 'knowledge-detail')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const result = await resolveKnowledgeBase(
id,
parsed.data.query.workspaceId,
userId,
rateLimit,
'write'
)
if (result instanceof NextResponse) return result
await deleteKnowledgeBase(id, requestId)
recordAudit({
workspaceId: parsed.data.query.workspaceId,
actorId: userId,
action: AuditAction.KNOWLEDGE_BASE_DELETED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: result.kb.name,
description: `Deleted knowledge base "${result.kb.name}" via API`,
request,
})
return NextResponse.json({
success: true,
data: {
message: 'Knowledge base deleted successfully',
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to delete knowledge base')
}
}
)
+96
View File
@@ -0,0 +1,96 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1CreateKnowledgeBaseContract,
v1ListKnowledgeBasesContract,
} from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { EMBEDDING_DIMENSIONS, getConfiguredEmbeddingModel } from '@/lib/knowledge/embeddings'
import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service'
import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils'
import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware'
export const dynamic = 'force-dynamic'
export const revalidate = 0
/** GET /api/v1/knowledge — List knowledge bases in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
const auth = await authenticateRequest(request, 'knowledge')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1ListKnowledgeBasesContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId } = parsed.data.query
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
if (accessError) return accessError
const knowledgeBases = await getKnowledgeBases(userId, workspaceId)
return NextResponse.json({
success: true,
data: {
knowledgeBases: knowledgeBases.map(formatKnowledgeBase),
totalCount: knowledgeBases.length,
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to list knowledge bases')
}
})
/** POST /api/v1/knowledge — Create a new knowledge base. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = await authenticateRequest(request, 'knowledge')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1CreateKnowledgeBaseContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId, name, description, chunkingConfig } = parsed.data.body
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
if (accessError) return accessError
const kb = await createKnowledgeBase(
{
name,
description,
workspaceId,
userId,
embeddingModel: getConfiguredEmbeddingModel(),
embeddingDimension: EMBEDDING_DIMENSIONS,
chunkingConfig: chunkingConfig ?? { maxSize: 1024, minSize: 100, overlap: 200 },
},
requestId
)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.KNOWLEDGE_BASE_CREATED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: kb.id,
resourceName: kb.name,
description: `Created knowledge base "${kb.name}" via API`,
metadata: { chunkingConfig },
request,
})
return NextResponse.json({
success: true,
data: {
knowledgeBase: formatKnowledgeBase(kb),
message: 'Knowledge base created successfully',
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to create knowledge base')
}
})
@@ -0,0 +1,192 @@
/**
* Tests for v1 knowledge search API route.
* Specifically guards the per-KB embedding model resolution and the
* multi-model rejection so the v1 endpoint stays in lockstep with the
* internal route.
*
* @vitest-environment node
*/
import { createMockRequest, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns } from '@sim/testing'
import { getErrorMessage } from '@sim/utils/errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockHandleVectorOnlySearch,
mockHandleTagOnlySearch,
mockHandleTagAndVectorSearch,
mockGetQueryStrategy,
mockGenerateSearchEmbedding,
mockGetDocumentMetadataByIds,
mockAuthenticateRequest,
mockValidateWorkspaceAccess,
} = vi.hoisted(() => ({
mockHandleVectorOnlySearch: vi.fn(),
mockHandleTagOnlySearch: vi.fn(),
mockHandleTagAndVectorSearch: vi.fn(),
mockGetQueryStrategy: vi.fn(),
mockGenerateSearchEmbedding: vi.fn(),
mockGetDocumentMetadataByIds: vi.fn(),
mockAuthenticateRequest: vi.fn(),
mockValidateWorkspaceAccess: vi.fn(),
}))
vi.mock('@/app/api/knowledge/search/utils', () => ({
handleVectorOnlySearch: mockHandleVectorOnlySearch,
handleTagOnlySearch: mockHandleTagOnlySearch,
handleTagAndVectorSearch: mockHandleTagAndVectorSearch,
getQueryStrategy: mockGetQueryStrategy,
generateSearchEmbedding: mockGenerateSearchEmbedding,
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
}))
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }),
}))
vi.mock('@/app/api/v1/middleware', () => ({
authenticateRequest: mockAuthenticateRequest,
validateWorkspaceAccess: mockValidateWorkspaceAccess,
}))
vi.mock('@/app/api/v1/knowledge/utils', () => ({
handleError: (e: unknown) =>
new Response(JSON.stringify({ error: getErrorMessage(e, 'error') }), {
status: 500,
}),
}))
vi.mock('@/lib/knowledge/tags/service', () => ({
getDocumentTagDefinitions: vi.fn().mockResolvedValue([]),
}))
import { POST } from '@/app/api/v1/knowledge/search/route'
const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
const baseKb = (id: string, embeddingModel: string) => ({
id,
userId: 'user-1',
name: `KB ${id}`,
workspaceId: 'ws-1',
embeddingModel,
deletedAt: null,
})
describe('v1 knowledge search route — per-KB embedding model', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuthenticateRequest.mockResolvedValue({
requestId: 'req-1',
userId: 'user-1',
rateLimit: {},
})
mockValidateWorkspaceAccess.mockResolvedValue(null)
mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 })
mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3])
mockHandleVectorOnlySearch.mockResolvedValue([])
mockGetDocumentMetadataByIds.mockResolvedValue({})
})
it('passes the KB embedding model into generateSearchEmbedding', async () => {
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'),
})
const req = createMockRequest('POST', {
workspaceId: 'ws-1',
knowledgeBaseIds: 'kb-gemini',
query: 'hello',
})
const res = await POST(req)
expect(res.status).toBe(200)
expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith(
'hello',
'gemini-embedding-001',
'ws-1'
)
})
it('rejects cross-KB queries with mixed embedding models', async () => {
mockCheckKnowledgeBaseAccess
.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: baseKb('kb-openai', 'text-embedding-3-small'),
})
.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'),
})
const req = createMockRequest('POST', {
workspaceId: 'ws-1',
knowledgeBaseIds: ['kb-openai', 'kb-gemini'],
query: 'hello',
})
const res = await POST(req)
expect(res.status).toBe(400)
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled()
})
it('surfaces sourceUrl from document metadata in search results', async () => {
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'),
})
mockHandleVectorOnlySearch.mockResolvedValue([
{
documentId: 'doc-confluence',
knowledgeBaseId: 'kb-confluence',
content: 'page content',
chunkIndex: 0,
distance: 0.1,
},
])
mockGetDocumentMetadataByIds.mockResolvedValue({
'doc-confluence': {
filename: 'Runbook.md',
sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345',
},
})
const req = createMockRequest('POST', {
workspaceId: 'ws-1',
knowledgeBaseIds: 'kb-confluence',
query: 'runbook',
})
const res = await POST(req)
const body = await res.json()
expect(res.status).toBe(200)
expect(body.data.results[0].sourceUrl).toBe(
'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345'
)
expect(body.data.results[0].documentName).toBe('Runbook.md')
})
it('allows tag-only search across mixed embedding models', async () => {
mockHandleTagOnlySearch.mockResolvedValue([])
mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({
hasAccess: true,
knowledgeBase: baseKb('kb-mixed', 'text-embedding-3-small'),
})
const req = createMockRequest('POST', {
workspaceId: 'ws-1',
knowledgeBaseIds: 'kb-mixed',
tagFilters: [{ tagName: 'category', operator: 'eq', value: 'docs' }],
})
const res = await POST(req)
expect(res.status).toBe(400)
// tagName "category" is undefined in our empty getDocumentTagDefinitions mock,
// so the route returns 400 before reaching the search handlers — but crucially
// it never tries to generate an embedding.
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,279 @@
import { type NextRequest, NextResponse } from 'next/server'
import { v1KnowledgeSearchContract } from '@/lib/api/contracts/v1/knowledge'
import { parseRequest } from '@/lib/api/server'
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings'
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
import type { StructuredFilter } from '@/lib/knowledge/types'
import {
generateSearchEmbedding,
getDocumentMetadataByIds,
getQueryStrategy,
handleTagAndVectorSearch,
handleTagOnlySearch,
handleVectorOnlySearch,
type SearchResult,
} from '@/app/api/knowledge/search/utils'
import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils'
import { handleError } from '@/app/api/v1/knowledge/utils'
import { authenticateRequest, validateWorkspaceAccess } from '@/app/api/v1/middleware'
export const dynamic = 'force-dynamic'
export const revalidate = 0
/** POST /api/v1/knowledge/search — Vector search across knowledge bases. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const auth = await authenticateRequest(request, 'knowledge-search')
if (auth instanceof NextResponse) return auth
const { requestId, userId, rateLimit } = auth
try {
const parsed = await parseRequest(v1KnowledgeSearchContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId, topK, query, tagFilters } = parsed.data.body
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
if (accessError) return accessError
// A query incurs hosted embedding (+ optional rerank) cost — gate the actor's
// usage and frozen status before spending. Tag-only search is free, so skip it.
if (query && query.trim().length > 0) {
const usage = await checkActorUsageLimits(userId, workspaceId)
if (usage.isExceeded) {
return NextResponse.json(
{ error: usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' },
{ status: 402 }
)
}
}
const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds)
? parsed.data.body.knowledgeBaseIds
: [parsed.data.body.knowledgeBaseIds]
const accessChecks = await Promise.all(
knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId))
)
const accessibleKbs = accessChecks
.filter(
(ac): ac is KnowledgeBaseAccessResult =>
ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId
)
.map((ac) => ac.knowledgeBase)
const accessibleKbIds = accessibleKbs.map((kb) => kb.id)
if (accessibleKbIds.length === 0) {
return NextResponse.json(
{ error: 'Knowledge base not found or access denied' },
{ status: 404 }
)
}
const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id))
if (inaccessibleKbIds.length > 0) {
return NextResponse.json(
{ error: `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` },
{ status: 404 }
)
}
let structuredFilters: StructuredFilter[] = []
const tagDefsCache = new Map<string, Awaited<ReturnType<typeof getDocumentTagDefinitions>>>()
if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) {
return NextResponse.json(
{ error: 'Tag filters are only supported when searching a single knowledge base' },
{ status: 400 }
)
}
if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) {
const kbId = accessibleKbIds[0]
const tagDefs = await getDocumentTagDefinitions(kbId)
tagDefsCache.set(kbId, tagDefs)
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
tagDefs.forEach((def) => {
displayNameToTagDef[def.displayName] = {
tagSlot: def.tagSlot,
fieldType: def.fieldType,
}
})
const undefinedTags: string[] = []
const typeErrors: string[] = []
for (const filter of tagFilters) {
const tagDef = displayNameToTagDef[filter.tagName]
if (!tagDef) {
undefinedTags.push(filter.tagName)
continue
}
const validationError = validateTagValue(
filter.tagName,
String(filter.value),
tagDef.fieldType
)
if (validationError) {
typeErrors.push(validationError)
}
}
if (undefinedTags.length > 0 || typeErrors.length > 0) {
const errorParts: string[] = []
if (undefinedTags.length > 0) {
errorParts.push(buildUndefinedTagsError(undefinedTags))
}
if (typeErrors.length > 0) {
errorParts.push(...typeErrors)
}
return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 })
}
structuredFilters = tagFilters.map((filter) => {
const tagDef = displayNameToTagDef[filter.tagName]!
return {
tagSlot: tagDef.tagSlot,
fieldType: tagDef.fieldType,
operator: filter.operator,
value: filter.value,
valueTo: filter.valueTo,
}
})
}
const hasQuery = query && query.trim().length > 0
const hasFilters = structuredFilters.length > 0
const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel)))
if (hasQuery && embeddingModels.length > 1) {
return NextResponse.json(
{
error:
'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.',
},
{ status: 400 }
)
}
const queryEmbeddingModel = embeddingModels[0]
let results: SearchResult[]
let queryEmbeddingIsBYOK: boolean | null = null
if (!hasQuery && hasFilters) {
results = await handleTagOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK,
structuredFilters,
})
} else if (hasQuery && hasFilters) {
const strategy = getQueryStrategy(accessibleKbIds.length, topK)
const queryEmbeddingResult = await generateSearchEmbedding(
query!,
queryEmbeddingModel,
workspaceId
)
queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
const queryVector = JSON.stringify(queryEmbeddingResult.embedding)
results = await handleTagAndVectorSearch({
knowledgeBaseIds: accessibleKbIds,
topK,
structuredFilters,
queryVector,
distanceThreshold: strategy.distanceThreshold,
})
} else if (hasQuery) {
const strategy = getQueryStrategy(accessibleKbIds.length, topK)
const queryEmbeddingResult = await generateSearchEmbedding(
query!,
queryEmbeddingModel,
workspaceId
)
queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK
const queryVector = JSON.stringify(queryEmbeddingResult.embedding)
results = await handleVectorOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK,
queryVector,
distanceThreshold: strategy.distanceThreshold,
})
} else {
return NextResponse.json(
{ error: 'Either query or tagFilters must be provided' },
{ status: 400 }
)
}
if (queryEmbeddingIsBYOK !== null) {
await recordSearchEmbeddingUsage({
userId,
workspaceId,
embeddingModel: queryEmbeddingModel,
query: query!,
isBYOK: queryEmbeddingIsBYOK,
sourceReference: `v1-kb-search:${requestId}`,
})
}
const tagDefsResults = await Promise.all(
accessibleKbIds.map(async (kbId) => {
try {
const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId))
const map: Record<string, string> = {}
tagDefs.forEach((def) => {
map[def.tagSlot] = def.displayName
})
return { kbId, map }
} catch {
return { kbId, map: {} as Record<string, string> }
}
})
)
const tagDefinitionsMap: Record<string, Record<string, string>> = {}
tagDefsResults.forEach(({ kbId, map }) => {
tagDefinitionsMap[kbId] = map
})
const documentIds = results.map((r) => r.documentId)
const documentMetadataMap = await getDocumentMetadataByIds(documentIds)
return NextResponse.json({
success: true,
data: {
results: results.map((result) => {
const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {}
const tags: Record<string, string | number | boolean | Date | null> = {}
ALL_TAG_SLOTS.forEach((slot) => {
const tagValue = result[slot as keyof SearchResult]
if (tagValue !== null && tagValue !== undefined) {
const displayName = kbTagMap[slot] || slot
tags[displayName] = tagValue as string | number | boolean | Date | null
}
})
const docMeta = documentMetadataMap[result.documentId]
return {
documentId: result.documentId,
documentName: docMeta?.filename || undefined,
sourceUrl: docMeta?.sourceUrl ?? null,
content: result.content,
chunkIndex: result.chunkIndex,
metadata: tags,
similarity: hasQuery ? 1 - result.distance : 1,
}
}),
query: query || '',
knowledgeBaseIds: accessibleKbIds,
topK,
totalResults: results.length,
},
})
} catch (error) {
return handleError(requestId, error, 'Failed to perform search')
}
})
+92
View File
@@ -0,0 +1,92 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { validationErrorResponseFromError } from '@/lib/api/server'
import { getKnowledgeBaseById } from '@/lib/knowledge/service'
import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types'
import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware'
const logger = createLogger('V1KnowledgeAPI')
/**
* Fetches a KB by ID, validates it exists, belongs to the workspace,
* and the user has permission. Returns the KB or a NextResponse error.
*/
export async function resolveKnowledgeBase(
id: string,
workspaceId: string,
userId: string,
rateLimit: RateLimitResult,
level: 'read' | 'write' = 'read'
): Promise<{ kb: KnowledgeBaseWithCounts } | NextResponse> {
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId, level)
if (accessError) return accessError
const kb = await getKnowledgeBaseById(id)
if (!kb) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
if (kb.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}
return { kb }
}
/**
* Serializes a date value for JSON responses.
*/
export function serializeDate(date: Date | string | null | undefined): string | null {
if (date === null || date === undefined) return null
if (date instanceof Date) return date.toISOString()
return String(date)
}
/**
* Formats a KnowledgeBaseWithCounts into the API response shape.
*/
export function formatKnowledgeBase(kb: KnowledgeBaseWithCounts) {
return {
id: kb.id,
name: kb.name,
description: kb.description,
tokenCount: kb.tokenCount,
embeddingModel: kb.embeddingModel,
embeddingDimension: kb.embeddingDimension,
chunkingConfig: kb.chunkingConfig,
docCount: kb.docCount,
connectorTypes: kb.connectorTypes,
createdAt: serializeDate(kb.createdAt),
updatedAt: serializeDate(kb.updatedAt),
}
}
/**
* Handles unexpected errors with consistent logging and response.
*/
export function handleError(
requestId: string,
error: unknown,
defaultMessage: string
): NextResponse {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof Error) {
if (error.message.includes('does not have permission')) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const isStorageLimitError =
error.message.includes('Storage limit exceeded') || error.message.includes('storage limit')
if (isStorageLimitError) {
return NextResponse.json({ error: 'Storage limit exceeded' }, { status: 413 })
}
const isDuplicate = error.message.includes('already exists')
if (isDuplicate) {
return NextResponse.json({ error: 'Resource already exists' }, { status: 409 })
}
}
logger.error(`[${requestId}] ${defaultMessage}:`, error)
return NextResponse.json({ error: defaultMessage }, { status: 500 })
}
+127
View File
@@ -0,0 +1,127 @@
import { db } from '@sim/db'
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1LogDetailsAPI')
export const revalidate = 0
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'logs-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1GetLogContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid log ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const rows = await db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
costTotal: workflowExecutionLogs.costTotal,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
})
.from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.where(eq(workflowExecutionLogs.id, id))
.limit(1)
const log = rows[0]
if (!log) {
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
}
const accessError = await validateWorkspaceAccess(rateLimit, userId, log.workspaceId)
if (accessError) {
return NextResponse.json({ error: 'Log not found' }, { status: 404 })
}
const workflowSummary = {
id: log.workflowId,
name: log.workflowName || 'Deleted Workflow',
description: log.workflowDescription,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
deleted: !log.workflowName,
}
const response = {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
level: log.level,
trigger: log.trigger,
startedAt: log.startedAt.toISOString(),
endedAt: log.endedAt?.toISOString() || null,
totalDurationMs: log.totalDurationMs,
files: log.files || undefined,
workflow: workflowSummary,
executionData: (await materializeExecutionData(
log.executionData as Record<string, unknown> | null,
{
workspaceId: log.workspaceId,
workflowId: log.workflowId,
executionId: log.executionId,
}
)) as any,
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
createdAt: log.createdAt.toISOString(),
}
// Get user's workflow execution limits and usage
const limits = await getUserLimits(userId)
// Create response with limits information
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: any) {
logger.error(`[${requestId}] Log details fetch error`, { error: error.message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,102 @@
import { db } from '@sim/db'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetExecutionContract } from '@/lib/api/contracts/v1/logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1ExecutionAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ executionId: string }> }) => {
try {
const rateLimit = await checkRateLimit(request, 'logs-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1GetExecutionContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid execution ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { executionId } = parsed.data.params
logger.debug(`Fetching execution data for: ${executionId}`)
const rows = await db
.select()
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
if (rows.length === 0) {
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
}
const workflowLog = rows[0]
const accessError = await validateWorkspaceAccess(rateLimit, userId, workflowLog.workspaceId)
if (accessError) {
return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 })
}
const [snapshot] = await db
.select()
.from(workflowExecutionSnapshots)
.where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId))
.limit(1)
if (!snapshot) {
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
}
const response = {
executionId,
workflowId: workflowLog.workflowId,
workflowState: snapshot.stateData,
executionMetadata: {
trigger: workflowLog.trigger,
startedAt: workflowLog.startedAt.toISOString(),
endedAt: workflowLog.endedAt?.toISOString(),
totalDurationMs: workflowLog.totalDurationMs,
// Sourced from the cost_total projection of the usage_log ledger
// (the deprecated cost jsonb column was dropped).
cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null,
},
}
logger.debug(`Successfully fetched execution data for: ${executionId}`)
logger.debug(
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
)
// Get user's workflow execution limits and usage
const limits = await getUserLimits(userId)
// Create response with limits information
const apiResponse = createApiResponse(
{
...response,
},
limits,
rateLimit
)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error) {
logger.error('Error fetching execution data:', error)
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
}
}
)
+110
View File
@@ -0,0 +1,110 @@
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
export interface LogFilters {
workspaceId: string
workflowIds?: string[]
folderIds?: string[]
triggers?: string[]
level?: 'info' | 'error'
startDate?: Date
endDate?: Date
executionId?: string
minDurationMs?: number
maxDurationMs?: number
minCost?: number
maxCost?: number
model?: string
cursor?: {
startedAt: string
id: string
}
order?: 'desc' | 'asc'
}
export function buildLogFilters(filters: LogFilters): SQL<unknown> {
const conditions: SQL<unknown>[] = []
conditions.push(eq(workflowExecutionLogs.workspaceId, filters.workspaceId))
// Cursor-based pagination
if (filters.cursor) {
const cursorDate = new Date(filters.cursor.startedAt)
if (filters.order === 'desc') {
conditions.push(
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) < (${cursorDate}, ${filters.cursor.id})`
)
} else {
conditions.push(
sql`(${workflowExecutionLogs.startedAt}, ${workflowExecutionLogs.id}) > (${cursorDate}, ${filters.cursor.id})`
)
}
}
// Workflow IDs filter
if (filters.workflowIds && filters.workflowIds.length > 0) {
conditions.push(inArray(workflow.id, filters.workflowIds))
}
// Folder IDs filter
if (filters.folderIds && filters.folderIds.length > 0) {
conditions.push(inArray(workflow.folderId, filters.folderIds))
}
// Triggers filter
if (filters.triggers && filters.triggers.length > 0 && !filters.triggers.includes('all')) {
conditions.push(inArray(workflowExecutionLogs.trigger, filters.triggers))
}
// Level filter
if (filters.level) {
conditions.push(eq(workflowExecutionLogs.level, filters.level))
}
// Date range filters
if (filters.startDate) {
conditions.push(gte(workflowExecutionLogs.startedAt, filters.startDate))
}
if (filters.endDate) {
conditions.push(lte(workflowExecutionLogs.startedAt, filters.endDate))
}
// Search filter (execution ID)
if (filters.executionId) {
conditions.push(eq(workflowExecutionLogs.executionId, filters.executionId))
}
// Duration filters
if (filters.minDurationMs !== undefined) {
conditions.push(gte(workflowExecutionLogs.totalDurationMs, filters.minDurationMs))
}
if (filters.maxDurationMs !== undefined) {
conditions.push(lte(workflowExecutionLogs.totalDurationMs, filters.maxDurationMs))
}
// Cost filters — indexed projection of the usage_log ledger (dollars).
if (filters.minCost !== undefined) {
conditions.push(sql`${workflowExecutionLogs.costTotal} >= ${filters.minCost}`)
}
if (filters.maxCost !== undefined) {
conditions.push(sql`${workflowExecutionLogs.costTotal} <= ${filters.maxCost}`)
}
// Model filter — uses the models_used projection (includes zero-cost/BYOK
// models, which the usage_log ledger drops), preserving prior behavior.
if (filters.model) {
conditions.push(sql`${workflowExecutionLogs.modelsUsed} @> ARRAY[${filters.model}]::text[]`)
}
// Combine all conditions with AND
return conditions.length > 0 ? and(...conditions)! : sql`true`
}
export function getOrderBy(order: 'desc' | 'asc' = 'desc') {
return order === 'desc'
? desc(workflowExecutionLogs.startedAt)
: sql`${workflowExecutionLogs.startedAt} ASC`
}
+82
View File
@@ -0,0 +1,82 @@
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
import { RateLimiter } from '@/lib/core/rate-limiter'
export interface UserLimits {
workflowExecutionRateLimit: {
sync: {
requestsPerMinute: number
maxBurst: number
remaining: number
resetAt: string
}
async: {
requestsPerMinute: number
maxBurst: number
remaining: number
resetAt: string
}
}
usage: {
currentPeriodCost: number
limit: number
plan: string
isExceeded: boolean
}
}
export async function getUserLimits(userId: string): Promise<UserLimits> {
const [userSubscription, usageCheck, effectiveCost, rateLimiter] = await Promise.all([
getHighestPrioritySubscription(userId),
checkServerSideUsageLimits(userId),
getEffectiveCurrentPeriodCost(userId),
Promise.resolve(new RateLimiter()),
])
const [syncStatus, asyncStatus] = await Promise.all([
rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', false),
rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, 'api', true),
])
return {
workflowExecutionRateLimit: {
sync: {
requestsPerMinute: syncStatus.requestsPerMinute,
maxBurst: syncStatus.maxBurst,
remaining: syncStatus.remaining,
resetAt: syncStatus.resetAt.toISOString(),
},
async: {
requestsPerMinute: asyncStatus.requestsPerMinute,
maxBurst: asyncStatus.maxBurst,
remaining: asyncStatus.remaining,
resetAt: asyncStatus.resetAt.toISOString(),
},
},
usage: {
currentPeriodCost: effectiveCost,
limit: usageCheck.limit,
plan: userSubscription?.plan || 'free',
isExceeded: usageCheck.isExceeded,
},
}
}
export function createApiResponse<T>(
data: T,
limits: UserLimits,
apiRateLimit: { limit: number; remaining: number; resetAt: Date }
) {
return {
body: {
...data,
limits,
},
headers: {
'X-RateLimit-Limit': apiRateLimit.limit.toString(),
'X-RateLimit-Remaining': apiRateLimit.remaining.toString(),
'X-RateLimit-Reset': apiRateLimit.resetAt.toISOString(),
},
}
}
+211
View File
@@ -0,0 +1,211 @@
import { db } from '@sim/db'
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1LogsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface CursorData {
startedAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'logs')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(
v1ListLogsContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{
error: getValidationErrorMessage(error, 'Invalid parameters'),
details: error.issues,
},
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.query
const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read')
if (accessError) return accessError
logger.info(`[${requestId}] Fetching logs for workspace ${params.workspaceId}`, {
userId,
filters: {
workflowIds: params.workflowIds,
triggers: params.triggers,
level: params.level,
},
})
const filters = {
workspaceId: params.workspaceId,
workflowIds: params.workflowIds?.split(',').filter(Boolean),
folderIds: params.folderIds?.split(',').filter(Boolean),
triggers: params.triggers?.split(',').filter(Boolean),
level: params.level,
startDate: params.startDate ? new Date(params.startDate) : undefined,
endDate: params.endDate ? new Date(params.endDate) : undefined,
executionId: params.executionId,
minDurationMs: params.minDurationMs,
maxDurationMs: params.maxDurationMs,
minCost: params.minCost,
maxCost: params.maxCost,
model: params.model,
cursor: params.cursor ? decodeCursor(params.cursor) || undefined : undefined,
order: params.order,
}
const conditions = buildLogFilters(filters)
const orderBy = getOrderBy(params.order)
const baseQuery = db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
executionId: workflowExecutionLogs.executionId,
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
costTotal: workflowExecutionLogs.costTotal,
files: workflowExecutionLogs.files,
executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`,
workflowName: workflow.name,
workflowDescription: workflow.description,
})
.from(workflowExecutionLogs)
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
const logs = await baseQuery
.where(conditions)
.orderBy(orderBy)
.limit(params.limit + 1)
const hasMore = logs.length > params.limit
const data = logs.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const lastLog = data[data.length - 1]
nextCursor = encodeCursor({
startedAt: lastLog.startedAt.toISOString(),
id: lastLog.id,
})
}
const needsMaterialize =
params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans)
const buildBase = (log: (typeof data)[number]) => {
const result: any = {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
deploymentVersionId: log.deploymentVersionId,
level: log.level,
trigger: log.trigger,
startedAt: log.startedAt.toISOString(),
endedAt: log.endedAt?.toISOString() || null,
totalDurationMs: log.totalDurationMs,
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
files: log.files || null,
}
if (params.details === 'full') {
result.workflow = {
id: log.workflowId,
name: log.workflowName || 'Deleted Workflow',
description: log.workflowDescription,
deleted: !log.workflowName,
}
}
return result
}
const formattedLogs = needsMaterialize
? await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => {
const result = buildBase(log)
if (log.executionData) {
const execData = (await materializeExecutionData(
log.executionData as Record<string, unknown> | null,
{
workspaceId: log.workspaceId,
workflowId: log.workflowId,
executionId: log.executionId,
}
)) as any
if (params.includeFinalOutput && execData.finalOutput) {
result.finalOutput = execData.finalOutput
}
if (params.includeTraceSpans && execData.traceSpans) {
result.traceSpans = execData.traceSpans
}
}
return result
})
: data.map(buildBase)
const limits = await getUserLimits(userId)
const response = createApiResponse(
{
data: formattedLogs,
nextCursor,
},
limits,
rateLimit // This is the API endpoint rate limit, not workflow execution limits
)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: any) {
logger.error(`[${requestId}] Logs fetch error`, { error: error.message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+214
View File
@@ -0,0 +1,214 @@
import { createLogger } from '@sim/logger'
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
import { type NextRequest, NextResponse } from 'next/server'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter'
import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
import { authenticateV1Request } from '@/app/api/v1/auth'
const logger = createLogger('V1Middleware')
const rateLimiter = new RateLimiter()
export type V1Endpoint =
| 'logs'
| 'logs-detail'
| 'workflows'
| 'workflow-detail'
| 'workflow-deploy'
| 'workflow-rollback'
| 'audit-logs'
| 'tables'
| 'table-detail'
| 'table-rows'
| 'table-row-detail'
| 'table-columns'
| 'files'
| 'file-detail'
| 'knowledge'
| 'knowledge-detail'
| 'knowledge-search'
| 'copilot-chat'
export interface RateLimitResult {
allowed: boolean
remaining: number
resetAt: Date
limit: number
retryAfterMs?: number
userId?: string
workspaceId?: string
keyType?: 'personal' | 'workspace'
error?: string
}
export interface AuthorizedRequest {
requestId: string
userId: string
rateLimit: RateLimitResult
}
export async function checkRateLimit(
request: NextRequest,
endpoint: V1Endpoint = 'logs'
): Promise<RateLimitResult> {
try {
const auth = await authenticateV1Request(request)
if (!auth.authenticated) {
return {
allowed: false,
remaining: 0,
limit: 10,
resetAt: new Date(),
error: auth.error,
}
}
const userId = auth.userId!
const subscription = await getHighestPrioritySubscription(userId)
const result = await rateLimiter.checkRateLimitWithSubscription(
userId,
subscription,
'api-endpoint',
false
)
if (!result.allowed) {
logger.warn(`Rate limit exceeded for user ${userId}`, {
endpoint,
remaining: result.remaining,
resetAt: result.resetAt,
})
}
const plan = (subscription?.plan || 'free') as SubscriptionPlan
const config = getRateLimit(plan, 'api-endpoint')
return {
allowed: result.allowed,
remaining: result.remaining,
resetAt: result.resetAt,
limit: config.refillRate,
retryAfterMs: result.retryAfterMs,
userId,
workspaceId: auth.workspaceId,
keyType: auth.keyType,
}
} catch (error) {
logger.error('Rate limit check error', { error })
return {
allowed: false,
remaining: 0,
limit: 10,
resetAt: new Date(Date.now() + 60000),
error: 'Rate limit check failed',
}
}
}
/**
* Authenticates and rate-limits a v1 API request.
* Returns NextResponse on failure, AuthorizedRequest on success.
*/
export async function authenticateRequest(
request: NextRequest,
endpoint: V1Endpoint
): Promise<AuthorizedRequest | NextResponse> {
const requestId = generateRequestId()
const rateLimit = await checkRateLimit(request, endpoint)
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
return { requestId, userId: rateLimit.userId!, rateLimit }
}
export function createRateLimitResponse(result: RateLimitResult): NextResponse {
const headers = {
'X-RateLimit-Limit': result.limit.toString(),
'X-RateLimit-Remaining': result.remaining.toString(),
'X-RateLimit-Reset': result.resetAt.toISOString(),
}
if (result.error) {
return NextResponse.json({ error: result.error || 'Unauthorized' }, { status: 401, headers })
}
const retryAfterSeconds = result.retryAfterMs
? Math.ceil(result.retryAfterMs / 1000)
: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)
return NextResponse.json(
{
error: 'Rate limit exceeded',
message: `API rate limit exceeded. Please retry after ${result.resetAt.toISOString()}`,
retryAfter: result.resetAt.getTime(),
},
{
status: 429,
headers: {
...headers,
'Retry-After': retryAfterSeconds.toString(),
},
}
)
}
/**
* Verify that the API key is allowed to access the requested workspace.
*
* Enforces two policies:
* - A workspace-scoped key may only target its own workspace.
* - A personal key is rejected when the workspace has disabled personal API
* keys (`allowPersonalApiKeys = false`), matching the workflow-execution
* surface in `app/api/workflows/middleware.ts`.
*/
export async function checkWorkspaceScope(
rateLimit: RateLimitResult,
requestedWorkspaceId: string
): Promise<NextResponse | null> {
if (
rateLimit.keyType === 'workspace' &&
rateLimit.workspaceId &&
rateLimit.workspaceId !== requestedWorkspaceId
) {
return NextResponse.json(
{ error: 'API key is not authorized for this workspace' },
{ status: 403 }
)
}
if (rateLimit.keyType === 'personal') {
const settings = await getWorkspaceBillingSettings(requestedWorkspaceId)
if (!settings?.allowPersonalApiKeys) {
return NextResponse.json(
{ error: 'Personal API keys are not allowed for this workspace' },
{ status: 403 }
)
}
}
return null
}
/**
* Validates workspace-scoped API key bounds and the user's workspace permission.
* Returns null on success, NextResponse on failure.
*/
export async function validateWorkspaceAccess(
rateLimit: RateLimitResult,
userId: string,
workspaceId: string,
level: PermissionType = 'read'
): Promise<NextResponse | null> {
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permissionSatisfies(permission, level)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
return null
}
@@ -0,0 +1,279 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1AddTableColumnContract,
v1DeleteTableColumnContract,
v1UpdateTableColumnContract,
} from '@/lib/api/contracts/v1/tables'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
addTableColumn,
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnType,
} from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableColumnsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface ColumnsRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/v1/tables/[tableId]/columns — Add a column to the table schema. */
export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-columns')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1AddTableColumnContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updatedTable = await addTableColumn(tableId, validated.column, requestId)
recordAudit({
workspaceId: validated.workspaceId,
actorId: userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Added column "${validated.column.name}" to table "${table.name}"`,
metadata: { column: validated.column },
request,
})
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof Error) {
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error.message === 'Table not found') {
return NextResponse.json({ error: error.message }, { status: 404 })
}
}
logger.error(`[${requestId}] Error adding column to table:`, error)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
}
})
/** PATCH /api/v1/tables/[tableId]/columns — Update a column (rename, type change, constraints). */
export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-columns')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1UpdateTableColumnContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const { updates } = validated
let updatedTable = null
if (updates.name) {
updatedTable = await renameColumn(
{ tableId, oldName: validated.columnName, newName: updates.name },
requestId
)
}
if (updates.type) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
requestId
)
}
if (updates.required !== undefined || updates.unique !== undefined) {
updatedTable = await updateColumnConstraints(
{
tableId,
columnName: updates.name ?? validated.columnName,
...(updates.required !== undefined ? { required: updates.required } : {}),
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
},
requestId
)
}
if (!updatedTable) {
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
}
recordAudit({
workspaceId: validated.workspaceId,
actorId: userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Updated column "${validated.columnName}" in table "${table.name}"`,
metadata: { columnName: validated.columnName, updates },
request,
})
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof Error) {
const msg = error.message
if (msg.includes('not found') || msg.includes('Table not found')) {
return NextResponse.json({ error: msg }, { status: 404 })
}
if (
msg.includes('already exists') ||
msg.includes('Cannot delete the last column') ||
msg.includes('Cannot set column') ||
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes('incompatible') ||
msg.includes('duplicate')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
}
logger.error(`[${requestId}] Error updating column in table:`, error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
})
/** DELETE /api/v1/tables/[tableId]/columns — Delete a column from the table schema. */
export const DELETE = withRouteHandler(
async (request: NextRequest, context: ColumnsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-columns')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DeleteTableColumnContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updatedTable = await deleteColumn(
{ tableId, columnName: validated.columnName },
requestId
)
recordAudit({
workspaceId: validated.workspaceId,
actorId: userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Deleted column "${validated.columnName}" from table "${table.name}"`,
metadata: { columnName: validated.columnName },
request,
})
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof Error) {
if (error.message.includes('not found') || error.message === 'Table not found') {
return NextResponse.json({ error: error.message }, { status: 404 })
}
if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error deleting column from table:`, error)
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
}
}
)
@@ -0,0 +1,159 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { v1DeleteTableContract, v1GetTableContract } from '@/lib/api/contracts/v1/tables'
import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableDetailAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface TableRouteParams {
params: Promise<{ tableId: string }>
}
/** GET /api/v1/tables/[tableId] — Get table details. */
export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1GetTableContract, request, context, {
validationErrorResponse: (error) => {
const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId'))
return NextResponse.json(
{
error: hasInvalidTableId
? 'Invalid table ID'
: 'workspaceId query parameter is required',
},
{ status: 400 }
)
},
})
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const { workspaceId } = parsed.data.query
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const schemaData = table.schema as TableSchema
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error getting table:`, error)
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
}
})
/** DELETE /api/v1/tables/[tableId] — Archive a table. */
export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DeleteTableContract, request, context, {
validationErrorResponse: (error) => {
const hasInvalidTableId = error.issues.some((issue) => issue.path.includes('tableId'))
return NextResponse.json(
{
error: hasInvalidTableId
? 'Invalid table ID'
: 'workspaceId query parameter is required',
},
{ status: 400 }
)
},
})
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const { workspaceId } = parsed.data.query
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
await deleteTable(tableId, requestId)
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.TABLE_DELETED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: result.table.name,
description: `Archived table "${result.table.name}"`,
request,
})
return NextResponse.json({
success: true,
data: {
message: 'Table archived successfully',
},
})
} catch (error) {
logger.error(`[${requestId}] Error deleting table:`, error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
})
@@ -0,0 +1,260 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1DeleteTableRowContract,
v1GetTableRowContract,
v1UpdateTableRowContract,
} from '@/lib/api/contracts/v1/tables'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
rowDataIdToName,
rowDataNameToId,
updateRow,
} from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableRowAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface RowRouteParams {
params: Promise<{ tableId: string; rowId: string }>
}
/** GET /api/v1/tables/[tableId]/rows/[rowId] — Get a single row. */
export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-row-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1GetTableRowContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { tableId, rowId } = parsed.data.params
const { workspaceId } = parsed.data.query
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [row] = await db
.select({
id: userTableRows.id,
data: userTableRows.data,
position: userTableRows.position,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
const nameById = buildNameById(result.table.schema as TableSchema)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: rowDataIdToName(row.data as RowData, nameById),
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
},
},
})
} catch (error) {
logger.error(`[${requestId}] Error getting row:`, error)
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
}
})
/** PATCH /api/v1/tables/[tableId]/rows/[rowId] — Partial update a single row. */
export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-row-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1UpdateTableRowContract, request, context)
if (!parsed.success) return parsed.response
const { tableId, rowId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const updatedRow = await updateRow(
{
tableId,
rowId,
data: rowDataNameToId(validated.data as RowData, idByName),
workspaceId: validated.workspaceId,
actorUserId: userId,
},
table,
requestId
)
// No `cancellationGuard` is passed here, so `updateRow` can't return null
// from this caller. Defensive narrowing for TypeScript.
if (!updatedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
// Auto-dispatch for user edits is handled inside `updateRow` (mode: 'new').
// Firing a second mode: 'incomplete' dispatch here would race with it AND
// bulk-clear sibling-group outputs.
return NextResponse.json({
success: true,
data: {
row: {
id: updatedRow.id,
data: rowDataIdToName(updatedRow.data, nameById),
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
? updatedRow.createdAt.toISOString()
: updatedRow.createdAt,
updatedAt:
updatedRow.updatedAt instanceof Date
? updatedRow.updatedAt.toISOString()
: updatedRow.updatedAt,
},
message: 'Row updated successfully',
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
const errorMessage = toError(error).message
if (errorMessage === 'Row not found') {
return NextResponse.json({ error: errorMessage }, { status: 404 })
}
if (
errorMessage.includes('Row size exceeds') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('must be unique') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('Cannot set unique column')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error updating row:`, error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
}
})
/** DELETE /api/v1/tables/[tableId]/rows/[rowId] — Delete a single row. */
export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-row-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DeleteTableRowContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'workspaceId query parameter is required' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { tableId, rowId } = parsed.data.params
const { workspaceId } = parsed.data.query
const scopeError = await checkWorkspaceScope(rateLimit, workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [deletedRow] = await db
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.returning({ id: userTableRows.id })
if (!deletedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
return NextResponse.json({
success: true,
data: {
message: 'Row deleted successfully',
deletedCount: 1,
},
})
} catch (error) {
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
})
@@ -0,0 +1,458 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
type V1BatchInsertTableRowsBody,
v1CreateTableRowContract,
v1DeleteTableRowsContract,
v1ListTableRowsContract,
v1UpdateRowsByFilterContract,
} from '@/lib/api/contracts/v1/tables'
import {
parseRequest,
validationErrorResponse,
validationErrorResponseFromError,
} from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { Filter, RowData, TableSchema } from '@/lib/table'
import {
batchInsertRows,
buildIdByName,
buildNameById,
deleteRowsByFilter,
deleteRowsByIds,
filterNamesToIds,
insertRow,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
updateRowsByFilter,
validateBatchRows,
validateRowData,
validateRowSize,
} from '@/lib/table'
import { queryRows } from '@/lib/table/rows/service'
import { TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess, rowWriteErrorResponse } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableRowsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface TableRowsRouteParams {
params: Promise<{ tableId: string }>
}
async function handleBatchInsert(
requestId: string,
tableId: string,
validated: V1BatchInsertTableRowsBody,
userId: string
): Promise<NextResponse> {
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// External callers key row data by column name; storage keys by id.
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName))
const validation = await validateBatchRows({
rows,
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
try {
const insertedRows = await batchInsertRows(
{
tableId,
rows,
workspaceId: validated.workspaceId,
userId,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: rowDataIdToName(r.data, nameById),
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
})),
insertedCount: insertedRows.length,
message: `Successfully inserted ${insertedRows.length} rows`,
},
})
} catch (error) {
const response = rowWriteErrorResponse(error)
if (response) return response
logger.error(`[${requestId}] Error batch inserting rows:`, error)
return NextResponse.json({ error: 'Failed to insert rows' }, { status: 500 })
}
}
/** GET /api/v1/tables/[tableId]/rows — Query rows with filtering, sorting, pagination. */
export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1ListTableRowsContract, request, context, {
validationErrorResponse: (error) => {
const hasJsonError = error.issues.some(
(issue) =>
issue.message === 'Invalid filter JSON' || issue.message === 'Invalid sort JSON'
)
if (hasJsonError) {
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
}
return validationErrorResponse(error)
},
})
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.query
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'read')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// Translate name-keyed filter/sort fields → column ids; translate rows back.
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const filter = validated.filter
? filterNamesToIds(validated.filter as Filter, idByName)
: undefined
const sort = validated.sort ? sortNamesToIds(validated.sort, idByName) : undefined
const result = await queryRows(
table,
{
filter,
sort,
limit: validated.limit,
offset: validated.offset,
includeTotal: validated.includeTotal,
withExecutions: false,
},
requestId
)
return NextResponse.json({
success: true,
data: {
rows: result.rows.map((r) => ({
id: r.id,
data: rowDataIdToName(r.data, nameById),
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
rowCount: result.rowCount,
totalCount: result.totalCount,
limit: result.limit,
offset: result.offset,
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error querying rows:`, error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
})
/** POST /api/v1/tables/[tableId]/rows — Insert row(s). Supports single or batch. */
export const POST = withRouteHandler(
async (request: NextRequest, context: TableRowsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1CreateTableRowContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
if ('rows' in parsed.data.body) {
const batchValidated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, batchValidated.workspaceId)
if (scopeError) return scopeError
return handleBatchInsert(requestId, tableId, batchValidated, userId)
}
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const rowData = rowDataNameToId(validated.data as RowData, idByName)
const validation = await validateRowData({
rowData,
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
const row = await insertRow(
{
tableId,
data: rowData,
workspaceId: validated.workspaceId,
userId,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: rowDataIdToName(row.data, nameById),
position: row.position,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
},
message: 'Row inserted successfully',
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
const response = rowWriteErrorResponse(error)
if (response) return response
logger.error(`[${requestId}] Error inserting row:`, error)
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
}
}
)
/** PUT /api/v1/tables/[tableId]/rows — Bulk update rows by filter. */
export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1UpdateRowsByFilterContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const idByName = buildIdByName(table.schema as TableSchema)
const patchData = rowDataNameToId(validated.data as RowData, idByName)
const sizeValidation = validateRowSize(patchData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Validation error', details: sizeValidation.errors },
{ status: 400 }
)
}
const result = await updateRowsByFilter(
table,
{
filter: filterNamesToIds(validated.filter as Filter, idByName),
data: patchData,
limit: validated.limit,
actorUserId: userId,
},
requestId
)
if (result.affectedCount === 0) {
return NextResponse.json({
success: true,
data: {
message: 'No rows matched the filter criteria',
updatedCount: 0,
},
})
}
return NextResponse.json({
success: true,
data: {
message: 'Rows updated successfully',
updatedCount: result.affectedCount,
updatedRowIds: result.affectedRowIds,
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
const response = rowWriteErrorResponse(error)
if (response) return response
logger.error(`[${requestId}] Error updating rows by filter:`, error)
return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 })
}
})
/** DELETE /api/v1/tables/[tableId]/rows — Delete rows by filter or IDs. */
export const DELETE = withRouteHandler(
async (request: NextRequest, context: TableRowsRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DeleteTableRowsContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
if (validated.rowIds) {
const result = await deleteRowsByIds(
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
requestId
)
return NextResponse.json({
success: true,
data: {
message:
result.deletedCount === 0
? 'No matching rows found for the provided IDs'
: 'Rows deleted successfully',
deletedCount: result.deletedCount,
deletedRowIds: result.deletedRowIds,
requestedCount: result.requestedCount,
...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}),
},
})
}
const idByName = buildIdByName(table.schema as TableSchema)
const result = await deleteRowsByFilter(
table,
{
filter: filterNamesToIds(validated.filter as Filter, idByName),
limit: validated.limit,
},
requestId
)
return NextResponse.json({
success: true,
data: {
message:
result.affectedCount === 0
? 'No rows matched the filter criteria'
: 'Rows deleted successfully',
deletedCount: result.affectedCount,
deletedRowIds: result.affectedRowIds,
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
const response = rowWriteErrorResponse(error)
if (response) return response
logger.error(`[${requestId}] Error deleting rows:`, error)
return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 })
}
}
)
@@ -0,0 +1,114 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { v1UpsertTableRowContract } from '@/lib/api/contracts/v1/tables'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
import {
buildIdByName,
buildNameById,
rowDataIdToName,
rowDataNameToId,
upsertRow,
} from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableUpsertAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface UpsertRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/v1/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */
export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'table-rows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1UpsertTableRowContract, request, context)
if (!parsed.success) return parsed.response
const { tableId } = parsed.data.params
const validated = parsed.data.body
const scopeError = await checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const idByName = buildIdByName(table.schema as TableSchema)
const nameById = buildNameById(table.schema as TableSchema)
const upsertResult = await upsertRow(
{
tableId,
workspaceId: validated.workspaceId,
data: rowDataNameToId(validated.data as RowData, idByName),
userId,
conflictTarget: validated.conflictTarget,
},
table,
requestId
)
return NextResponse.json({
success: true,
data: {
row: {
id: upsertResult.row.id,
data: rowDataIdToName(upsertResult.row.data, nameById),
createdAt:
upsertResult.row.createdAt instanceof Date
? upsertResult.row.createdAt.toISOString()
: upsertResult.row.createdAt,
updatedAt:
upsertResult.row.updatedAt instanceof Date
? upsertResult.row.updatedAt.toISOString()
: upsertResult.row.updatedAt,
},
operation: upsertResult.operation,
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
const errorMessage = toError(error).message
if (
errorMessage.includes('unique column') ||
errorMessage.includes('Unique constraint violation') ||
errorMessage.includes('conflictTarget') ||
errorMessage.includes('row limit') ||
errorMessage.includes('Schema validation') ||
errorMessage.includes('Upsert requires') ||
errorMessage.includes('Row size exceeds')
) {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}
logger.error(`[${requestId}] Error upserting row:`, error)
return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 })
}
})
+172
View File
@@ -0,0 +1,172 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { v1CreateTableContract, v1ListTablesContract } from '@/lib/api/contracts/v1/tables'
import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createTable, getWorkspaceTableLimits, listTables, type TableSchema } from '@/lib/table'
import { normalizeColumn } from '@/app/api/table/utils'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TablesAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
/** GET /api/v1/tables — List all tables in a workspace. */
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'tables')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1ListTablesContract, request, {})
if (!parsed.success) return parsed.response
const { workspaceId } = parsed.data.query
const accessError = await validateWorkspaceAccess(rateLimit, userId, workspaceId)
if (accessError) return accessError
const tables = await listTables(workspaceId)
return NextResponse.json({
success: true,
data: {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
id: t.id,
name: t.name,
description: t.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: t.rowCount,
maxRows: t.maxRows,
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
}
}),
totalCount: tables.length,
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
logger.error(`[${requestId}] Error listing tables:`, error)
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
}
})
/** POST /api/v1/tables — Create a new table. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'tables')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1CreateTableContract, request, {})
if (!parsed.success) return parsed.response
const params = parsed.data.body
const accessError = await validateWorkspaceAccess(
rateLimit,
userId,
params.workspaceId,
'write'
)
if (accessError) return accessError
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
const normalizedSchema: TableSchema = {
columns: params.schema.columns.map(normalizeColumn),
}
const table = await createTable(
{
name: params.name,
description: params.description,
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId,
maxTables: planLimits.maxTables,
},
requestId
)
recordAudit({
workspaceId: params.workspaceId,
actorId: userId,
action: AuditAction.TABLE_CREATED,
resourceType: AuditResourceType.TABLE,
resourceId: table.id,
resourceName: table.name,
description: `Created table "${table.name}" via API`,
metadata: { columnCount: params.schema.columns.length },
request,
})
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: {
columns: (table.schema as TableSchema).columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
message: 'Table created successfully',
},
})
} catch (error) {
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
if (error instanceof Error) {
if (error.message.includes('maximum table limit')) {
return NextResponse.json({ error: error.message }, { status: 403 })
}
if (
error.message.includes('Invalid table name') ||
error.message.includes('Invalid schema') ||
error.message.includes('already exists')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
}
})
@@ -0,0 +1,271 @@
/**
* @vitest-environment node
*
* Tests for POST/DELETE /api/v1/workflows/[id]/deploy — verifies auth,
* workspace admin permission enforcement, optional body handling, and the
* mapping of orchestration results to v1 API responses.
*/
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckRateLimit,
mockValidateWorkspaceAccess,
mockPerformFullDeploy,
mockPerformFullUndeploy,
mockCaptureServerEvent,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockValidateWorkspaceAccess: vi.fn(),
mockPerformFullDeploy: vi.fn(),
mockPerformFullUndeploy: vi.fn(),
mockCaptureServerEvent: vi.fn(),
}))
vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
createRateLimitResponse: vi.fn(() =>
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
),
validateWorkspaceAccess: mockValidateWorkspaceAccess,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performFullDeploy: mockPerformFullDeploy,
performFullUndeploy: mockPerformFullUndeploy,
}))
vi.mock('@/app/api/v1/logs/meta', () => ({
getUserLimits: vi.fn().mockResolvedValue({}),
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: mockCaptureServerEvent,
}))
import { DELETE, POST } from '@/app/api/v1/workflows/[id]/deploy/route'
const WORKFLOW_ID = 'wf-1'
const WORKFLOW_RECORD = {
id: WORKFLOW_ID,
name: 'My Workflow',
workspaceId: 'ws-1',
isDeployed: true,
}
function makeContext(id = WORKFLOW_ID) {
return { params: Promise.resolve({ id }) }
}
function makeRequest(method: string, body?: unknown) {
return createMockRequest(
method,
body,
{},
`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy`
)
}
describe('POST /api/v1/workflows/[id]/deploy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
mockValidateWorkspaceAccess.mockResolvedValue(null)
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
mockPerformFullDeploy.mockResolvedValue({
success: true,
deployedAt: new Date('2026-06-12T00:00:00Z'),
version: 4,
warnings: undefined,
})
})
it('rejects unauthenticated requests', async () => {
mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' })
const response = await POST(makeRequest('POST'), makeContext())
expect(response.status).toBe(401)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
it('returns 404 when the workflow does not exist', async () => {
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null)
const response = await POST(makeRequest('POST'), makeContext())
expect(response.status).toBe(404)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
it('masks missing admin permission as 404', async () => {
mockValidateWorkspaceAccess.mockResolvedValue(
NextResponse.json({ error: 'Access denied' }, { status: 403 })
)
const response = await POST(makeRequest('POST'), makeContext())
expect(response.status).toBe(404)
expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ allowed: true }),
'user-1',
'ws-1',
'admin'
)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
it('rejects a malformed JSON body', async () => {
const request = new NextRequest(
new URL(`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/deploy`),
{
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: '{"name": "Release 4"',
}
)
const response = await POST(request, makeContext())
expect(response.status).toBe(400)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
it('rejects invalid version metadata', async () => {
const response = await POST(makeRequest('POST', { name: '' }), makeContext())
expect(response.status).toBe(400)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
it('deploys without a request body', async () => {
const response = await POST(makeRequest('POST'), makeContext())
expect(response.status).toBe(200)
expect(mockPerformFullDeploy).toHaveBeenCalledWith(
expect.objectContaining({
workflowId: WORKFLOW_ID,
userId: 'user-1',
versionName: undefined,
versionDescription: undefined,
})
)
const body = await response.json()
expect(body.data).toEqual({
id: WORKFLOW_ID,
isDeployed: true,
deployedAt: '2026-06-12T00:00:00.000Z',
version: 4,
warnings: [],
})
})
it('passes version metadata through to the deploy orchestration', async () => {
const response = await POST(
makeRequest('POST', { name: 'Release 4', description: 'Fixes the agent prompt' }),
makeContext()
)
expect(response.status).toBe(200)
expect(mockPerformFullDeploy).toHaveBeenCalledWith(
expect.objectContaining({
versionName: 'Release 4',
versionDescription: 'Fixes the agent prompt',
})
)
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
'user-1',
'workflow_deployed',
expect.objectContaining({ workflow_id: WORKFLOW_ID }),
expect.anything()
)
})
it('maps validation failures from the orchestration to 400', async () => {
mockPerformFullDeploy.mockResolvedValue({
success: false,
error: 'Invalid schedule configuration',
errorCode: 'validation',
})
const response = await POST(makeRequest('POST'), makeContext())
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('Invalid schedule configuration')
})
it('returns 423 when the workflow is locked', async () => {
workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError())
const response = await POST(makeRequest('POST'), makeContext())
expect(response.status).toBe(423)
expect(mockPerformFullDeploy).not.toHaveBeenCalled()
})
})
describe('DELETE /api/v1/workflows/[id]/deploy', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
mockValidateWorkspaceAccess.mockResolvedValue(null)
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
mockPerformFullUndeploy.mockResolvedValue({ success: true })
})
it('returns 400 when the workflow is not deployed', async () => {
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({
...WORKFLOW_RECORD,
isDeployed: false,
})
const response = await DELETE(makeRequest('DELETE'), makeContext())
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('Workflow is not deployed')
expect(mockPerformFullUndeploy).not.toHaveBeenCalled()
})
it('undeploys a deployed workflow', async () => {
const response = await DELETE(makeRequest('DELETE'), makeContext())
expect(response.status).toBe(200)
expect(mockPerformFullUndeploy).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: WORKFLOW_ID, userId: 'user-1' })
)
const body = await response.json()
expect(body.data).toEqual({
id: WORKFLOW_ID,
isDeployed: false,
deployedAt: null,
warnings: [],
})
expect(mockCaptureServerEvent).toHaveBeenCalledWith(
'user-1',
'workflow_undeployed',
expect.objectContaining({ workflow_id: WORKFLOW_ID }),
expect.anything()
)
})
it('masks missing admin permission as 404', async () => {
mockValidateWorkspaceAccess.mockResolvedValue(
NextResponse.json({ error: 'Access denied' }, { status: 403 })
)
const response = await DELETE(makeRequest('DELETE'), makeContext())
expect(response.status).toBe(404)
expect(mockPerformFullUndeploy).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,184 @@
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1DeployWorkflowBodySchema,
v1DeployWorkflowContract,
v1UndeployWorkflowContract,
} from '@/lib/api/contracts/v1/workflows'
import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
const logger = createLogger('V1WorkflowDeployAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 120
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'workflow-deploy')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1DeployWorkflowContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const rawBody = await parseOptionalJsonBody(request)
if (!rawBody.success) return rawBody.response
const body = v1DeployWorkflowBodySchema.safeParse(rawBody.data ?? {})
if (!body.success) {
return validationErrorResponse(body.error)
}
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
if (!target.ok) return target.response
const { workflow, workspaceId } = target
await assertWorkflowMutable(id)
logger.info(`[${requestId}] Deploying workflow ${id} via v1 API`, { userId })
const result = await performFullDeploy({
workflowId: id,
userId,
workflowName: workflow.name || undefined,
versionName: body.data.name,
versionDescription: body.data.description ?? undefined,
requestId,
request,
})
if (!result.success) {
return NextResponse.json(
{ error: result.error || 'Failed to deploy workflow' },
{ status: statusForOrchestrationError(result.errorCode) }
)
}
captureServerEvent(
userId,
'workflow_deployed',
{ workflow_id: id, workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
setOnce: { first_workflow_deployed_at: new Date().toISOString() },
}
)
const limits = await getUserLimits(userId)
const apiResponse = createApiResponse(
{
data: {
id,
isDeployed: true,
deployedAt: result.deployedAt?.toISOString() ?? null,
version: result.version,
warnings: result.warnings ?? [],
},
},
limits,
rateLimit
)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Workflow deploy error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'workflow-deploy')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1UndeployWorkflowContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
if (!target.ok) return target.response
const { workflow, workspaceId } = target
if (!workflow.isDeployed) {
return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 })
}
await assertWorkflowMutable(id)
logger.info(`[${requestId}] Undeploying workflow ${id} via v1 API`, { userId })
const result = await performFullUndeploy({ workflowId: id, userId, requestId })
if (!result.success) {
return NextResponse.json(
{ error: result.error || 'Failed to undeploy workflow' },
{ status: 500 }
)
}
captureServerEvent(
userId,
'workflow_undeployed',
{ workflow_id: id, workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
const limits = await getUserLimits(userId)
const apiResponse = createApiResponse(
{
data: {
id,
isDeployed: false,
deployedAt: null,
warnings: result.warnings ?? [],
},
},
limits,
rateLimit
)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Workflow undeploy error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
@@ -0,0 +1,216 @@
/**
* @vitest-environment node
*
* Tests for POST /api/v1/workflows/[id]/rollback — verifies target version
* resolution (previous version by default, explicit version when provided)
* and the mapping of activation results to v1 API responses.
*/
import { WorkflowLockedError } from '@sim/platform-authz/workflow'
import { createMockRequest, workflowAuthzMockFns } from '@sim/testing'
import { NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCheckRateLimit,
mockValidateWorkspaceAccess,
mockPerformActivateVersion,
mockFindPreviousDeploymentVersion,
} = vi.hoisted(() => ({
mockCheckRateLimit: vi.fn(),
mockValidateWorkspaceAccess: vi.fn(),
mockPerformActivateVersion: vi.fn(),
mockFindPreviousDeploymentVersion: vi.fn(),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
findPreviousDeploymentVersion: mockFindPreviousDeploymentVersion,
}))
vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
createRateLimitResponse: vi.fn(() =>
NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
),
validateWorkspaceAccess: mockValidateWorkspaceAccess,
}))
vi.mock('@/lib/workflows/orchestration', () => ({
performActivateVersion: mockPerformActivateVersion,
}))
vi.mock('@/app/api/v1/logs/meta', () => ({
getUserLimits: vi.fn().mockResolvedValue({}),
createApiResponse: vi.fn((body: unknown) => ({ body, headers: {} })),
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: vi.fn(),
}))
import { POST } from '@/app/api/v1/workflows/[id]/rollback/route'
const WORKFLOW_ID = 'wf-1'
const WORKFLOW_RECORD = {
id: WORKFLOW_ID,
name: 'My Workflow',
workspaceId: 'ws-1',
isDeployed: true,
}
function makeContext(id = WORKFLOW_ID) {
return { params: Promise.resolve({ id }) }
}
function makeRequest(body?: unknown) {
return createMockRequest(
'POST',
body,
{},
`http://localhost:3000/api/v1/workflows/${WORKFLOW_ID}/rollback`
)
}
describe('POST /api/v1/workflows/[id]/rollback', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'user-1' })
mockValidateWorkspaceAccess.mockResolvedValue(null)
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(WORKFLOW_RECORD)
workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined)
mockPerformActivateVersion.mockResolvedValue({
success: true,
deployedAt: new Date('2026-06-12T00:00:00Z'),
})
})
it('rejects unauthenticated requests', async () => {
mockCheckRateLimit.mockResolvedValue({ allowed: false, error: 'Invalid API key' })
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(401)
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('returns 404 when the workflow does not exist', async () => {
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue(null)
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(404)
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('returns 423 when the workflow is locked', async () => {
workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue(new WorkflowLockedError())
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(423)
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('rolls back to the previous version when no version is given', async () => {
mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: true, version: 4 })
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(200)
expect(mockPerformActivateVersion).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: WORKFLOW_ID, version: 4, userId: 'user-1' })
)
const body = await response.json()
expect(body.data).toEqual({
id: WORKFLOW_ID,
isDeployed: true,
deployedAt: '2026-06-12T00:00:00.000Z',
version: 4,
warnings: [],
})
})
it('returns 400 when the workflow is not deployed, even with an explicit version', async () => {
workflowAuthzMockFns.mockGetActiveWorkflowRecord.mockResolvedValue({
...WORKFLOW_RECORD,
isDeployed: false,
})
const response = await POST(makeRequest({ version: 2 }), makeContext())
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('Workflow is not deployed')
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('rolls back to an explicit version when provided', async () => {
const response = await POST(makeRequest({ version: 2 }), makeContext())
expect(response.status).toBe(200)
expect(mockPerformActivateVersion).toHaveBeenCalledWith(expect.objectContaining({ version: 2 }))
expect(mockFindPreviousDeploymentVersion).not.toHaveBeenCalled()
})
it('rejects a non-integer version', async () => {
const response = await POST(makeRequest({ version: 1.5 }), makeContext())
expect(response.status).toBe(400)
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('returns 400 when there is no active deployment to roll back from', async () => {
mockFindPreviousDeploymentVersion.mockResolvedValue({ ok: false, reason: 'no_active_version' })
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('Workflow has no active deployment to roll back from')
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('returns 400 when there is no previous version to roll back to', async () => {
mockFindPreviousDeploymentVersion.mockResolvedValue({
ok: false,
reason: 'no_previous_version',
})
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toBe('No previous deployment version to roll back to')
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
it('maps a missing target version to 404', async () => {
mockPerformActivateVersion.mockResolvedValue({
success: false,
error: 'Deployment version not found',
errorCode: 'not_found',
})
const response = await POST(makeRequest({ version: 99 }), makeContext())
expect(response.status).toBe(404)
})
it('masks missing admin permission as 404', async () => {
mockValidateWorkspaceAccess.mockResolvedValue(
NextResponse.json({ error: 'Access denied' }, { status: 403 })
)
const response = await POST(makeRequest(), makeContext())
expect(response.status).toBe(404)
expect(mockValidateWorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ allowed: true }),
'user-1',
'ws-1',
'admin'
)
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,128 @@
import { createLogger } from '@sim/logger'
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
v1RollbackWorkflowBodySchema,
v1RollbackWorkflowContract,
} from '@/lib/api/contracts/v1/workflows'
import { parseOptionalJsonBody, parseRequest, validationErrorResponse } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { performActivateVersion } from '@/lib/workflows/orchestration'
import { statusForOrchestrationError } from '@/lib/workflows/orchestration/types'
import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
import { resolveV1DeploymentWorkflow } from '@/app/api/v1/workflows/utils'
const logger = createLogger('V1WorkflowRollbackAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 120
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
try {
const rateLimit = await checkRateLimit(request, 'workflow-rollback')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1RollbackWorkflowContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
const rawBody = await parseOptionalJsonBody(request)
if (!rawBody.success) return rawBody.response
const body = v1RollbackWorkflowBodySchema.safeParse(rawBody.data ?? {})
if (!body.success) {
return validationErrorResponse(body.error)
}
const target = await resolveV1DeploymentWorkflow(rateLimit, userId, id)
if (!target.ok) return target.response
const { workflow, workspaceId } = target
if (!workflow.isDeployed) {
return NextResponse.json({ error: 'Workflow is not deployed' }, { status: 400 })
}
await assertWorkflowMutable(id)
let targetVersion = body.data.version
if (targetVersion === undefined) {
const previous = await findPreviousDeploymentVersion(id)
if (!previous.ok) {
const message =
previous.reason === 'no_active_version'
? 'Workflow has no active deployment to roll back from'
: 'No previous deployment version to roll back to'
return NextResponse.json({ error: message }, { status: 400 })
}
targetVersion = previous.version
}
logger.info(
`[${requestId}] Rolling back workflow ${id} to version ${targetVersion} via v1 API`,
{ userId }
)
const result = await performActivateVersion({
workflowId: id,
version: targetVersion,
userId,
workflow: workflow as Record<string, unknown>,
requestId,
request,
})
if (!result.success) {
return NextResponse.json(
{ error: result.error || 'Failed to roll back workflow' },
{ status: statusForOrchestrationError(result.errorCode) }
)
}
captureServerEvent(
userId,
'deployment_version_activated',
{ workflow_id: id, workspace_id: workspaceId, version: targetVersion },
{ groups: { workspace: workspaceId } }
)
const limits = await getUserLimits(userId)
const apiResponse = createApiResponse(
{
data: {
id,
isDeployed: true,
deployedAt: result.deployedAt?.toISOString() ?? null,
version: targetVersion,
warnings: result.warnings ?? [],
},
},
limits,
rateLimit
)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
if (error instanceof WorkflowLockedError) {
return NextResponse.json({ error: error.message }, { status: error.status })
}
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Workflow rollback error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
+100
View File
@@ -0,0 +1,100 @@
import { db } from '@sim/db'
import { workflowBlocks } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetWorkflowContract } from '@/lib/api/contracts/v1/workflows'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1WorkflowDetailsAPI')
export const revalidate = 0
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateId().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'workflow-detail')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(v1GetWorkflowContract, request, context, {
validationErrorResponse: () =>
NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }),
})
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
const workflowData = await getActiveWorkflowRecord(id)
if (!workflowData) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const accessError = await validateWorkspaceAccess(
rateLimit,
userId,
workflowData.workspaceId!
)
if (accessError) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const blockRows = await db
.select({
id: workflowBlocks.id,
type: workflowBlocks.type,
subBlocks: workflowBlocks.subBlocks,
})
.from(workflowBlocks)
.where(eq(workflowBlocks.workflowId, id))
const blocksRecord = Object.fromEntries(
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
)
const inputs = extractInputFieldsFromBlocks(blocksRecord)
const response = {
id: workflowData.id,
name: workflowData.name,
description: workflowData.description,
folderId: workflowData.folderId,
workspaceId: workflowData.workspaceId,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt?.toISOString() || null,
runCount: workflowData.runCount,
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
variables: workflowData.variables || {},
inputs,
createdAt: workflowData.createdAt.toISOString(),
updatedAt: workflowData.updatedAt.toISOString(),
}
const limits = await getUserLimits(userId)
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
)
+178
View File
@@ -0,0 +1,178 @@
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, gt, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListWorkflowsContract } from '@/lib/api/contracts/v1/workflows'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
createRateLimitResponse,
validateWorkspaceAccess,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1WorkflowsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface CursorData {
sortOrder: number
createdAt: string
id: string
}
function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
} catch {
return null
}
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)
try {
const rateLimit = await checkRateLimit(request, 'workflows')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
const parsed = await parseRequest(
v1ListWorkflowsContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{
error: getValidationErrorMessage(error, 'Invalid parameters'),
details: error.issues,
},
{ status: 400 }
),
}
)
if (!parsed.success) return parsed.response
const params = parsed.data.query
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
userId,
filters: {
folderId: params.folderId,
deployedOnly: params.deployedOnly,
},
})
const accessError = await validateWorkspaceAccess(rateLimit, userId, params.workspaceId)
if (accessError) return accessError
const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)]
if (params.folderId) {
conditions.push(eq(workflow.folderId, params.folderId))
}
if (params.deployedOnly) {
conditions.push(eq(workflow.isDeployed, true))
}
if (params.cursor) {
const cursorData = decodeCursor(params.cursor)
if (cursorData) {
const cursorCondition = or(
gt(workflow.sortOrder, cursorData.sortOrder),
and(
eq(workflow.sortOrder, cursorData.sortOrder),
gt(workflow.createdAt, new Date(cursorData.createdAt))
),
and(
eq(workflow.sortOrder, cursorData.sortOrder),
eq(workflow.createdAt, new Date(cursorData.createdAt)),
gt(workflow.id, cursorData.id)
)
)
if (cursorCondition) {
conditions.push(cursorCondition)
}
}
}
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
const rows = await db
.select({
id: workflow.id,
name: workflow.name,
description: workflow.description,
folderId: workflow.folderId,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
runCount: workflow.runCount,
lastRunAt: workflow.lastRunAt,
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
})
.from(workflow)
.where(and(...conditions))
.orderBy(...orderByClause)
.limit(params.limit + 1)
const hasMore = rows.length > params.limit
const data = rows.slice(0, params.limit)
let nextCursor: string | undefined
if (hasMore && data.length > 0) {
const lastWorkflow = data[data.length - 1]
nextCursor = encodeCursor({
sortOrder: lastWorkflow.sortOrder,
createdAt: lastWorkflow.createdAt.toISOString(),
id: lastWorkflow.id,
})
}
const formattedWorkflows = data.map((w) => ({
id: w.id,
name: w.name,
description: w.description,
folderId: w.folderId,
workspaceId: w.workspaceId,
isDeployed: w.isDeployed,
deployedAt: w.deployedAt?.toISOString() || null,
runCount: w.runCount,
lastRunAt: w.lastRunAt?.toISOString() || null,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
}))
const limits = await getUserLimits(userId)
const response = createApiResponse(
{
data: formattedWorkflows,
nextCursor,
},
limits,
rateLimit
)
return NextResponse.json(response.body, { headers: response.headers })
} catch (error: unknown) {
const message = getErrorMessage(error, 'Unknown error')
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
})
+39
View File
@@ -0,0 +1,39 @@
import { type ActiveWorkflowRecord, getActiveWorkflowRecord } from '@sim/platform-authz/workflow'
import { NextResponse } from 'next/server'
import { type RateLimitResult, validateWorkspaceAccess } from '@/app/api/v1/middleware'
function workflowNotFoundResponse(): NextResponse {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
/**
* Resolves the target workflow for a v1 deployment mutation: loads the active
* record and verifies the caller's admin permission on its workspace. Access
* failures are masked as 404, matching the v1 workflow read surface so
* unauthorized callers cannot probe workflow existence.
*/
export async function resolveV1DeploymentWorkflow(
rateLimit: RateLimitResult,
userId: string,
workflowId: string
): Promise<
| { ok: true; workflow: ActiveWorkflowRecord; workspaceId: string }
| { ok: false; response: NextResponse }
> {
const workflow = await getActiveWorkflowRecord(workflowId)
if (!workflow?.workspaceId) {
return { ok: false, response: workflowNotFoundResponse() }
}
const accessError = await validateWorkspaceAccess(
rateLimit,
userId,
workflow.workspaceId,
'admin'
)
if (accessError) {
return { ok: false, response: workflowNotFoundResponse() }
}
return { ok: true, workflow, workspaceId: workflow.workspaceId }
}