feat(admin): added admin APIs for admin management (#2206)

This commit is contained in:
Waleed
2025-12-04 20:52:32 -08:00
committed by GitHub
parent 1b903f2db5
commit ca818a6503
19 changed files with 1970 additions and 0 deletions

View File

@@ -24,3 +24,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces

View File

@@ -0,0 +1,79 @@
/**
* 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 { createHash, timingSafeEqual } from 'crypto'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('AdminAuth')
export interface AdminAuthSuccess {
authenticated: true
}
export 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 (!constantTimeCompare(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 }
}
/**
* Constant-time string comparison.
*
* @param a - First string to compare
* @param b - Second string to compare
* @returns True if strings are equal, false otherwise
*/
function constantTimeCompare(a: string, b: string): boolean {
const aHash = createHash('sha256').update(a).digest()
const bHash = createHash('sha256').update(b).digest()
return timingSafeEqual(aHash, bHash)
}

View File

@@ -0,0 +1,79 @@
/**
* Admin API v1
*
* A RESTful API for administrative operations on Sim.
*
* Authentication:
* Set ADMIN_API_KEY environment variable and use x-admin-key header.
*
* Endpoints:
* GET /api/v1/admin/users - List all users
* GET /api/v1/admin/users/:id - Get user details
* GET /api/v1/admin/workspaces - List all workspaces
* GET /api/v1/admin/workspaces/:id - Get workspace details
* GET /api/v1/admin/workspaces/:id/workflows - List workspace workflows
* DELETE /api/v1/admin/workspaces/:id/workflows - Delete all workspace workflows
* GET /api/v1/admin/workspaces/:id/folders - List workspace folders
* GET /api/v1/admin/workspaces/:id/export - Export workspace (ZIP/JSON)
* POST /api/v1/admin/workspaces/:id/import - Import into workspace
* GET /api/v1/admin/workflows - List all workflows
* GET /api/v1/admin/workflows/:id - Get workflow details
* DELETE /api/v1/admin/workflows/:id - Delete workflow
* GET /api/v1/admin/workflows/:id/export - Export workflow (JSON)
* POST /api/v1/admin/workflows/import - Import single workflow
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
export { authenticateAdminRequest } from '@/app/api/v1/admin/auth'
export type { AdminRouteHandler, AdminRouteHandlerWithParams } from '@/app/api/v1/admin/middleware'
export { withAdminAuth, withAdminAuthParams } from '@/app/api/v1/admin/middleware'
export {
badRequestResponse,
errorResponse,
forbiddenResponse,
internalErrorResponse,
listResponse,
notConfiguredResponse,
notFoundResponse,
singleResponse,
unauthorizedResponse,
} from '@/app/api/v1/admin/responses'
export type {
AdminErrorResponse,
AdminFolder,
AdminListResponse,
AdminSingleResponse,
AdminUser,
AdminWorkflow,
AdminWorkflowDetail,
AdminWorkspace,
AdminWorkspaceDetail,
DbUser,
DbWorkflow,
DbWorkflowFolder,
DbWorkspace,
FolderExportPayload,
ImportResult,
PaginationMeta,
PaginationParams,
VariableType,
WorkflowExportPayload,
WorkflowExportState,
WorkflowImportRequest,
WorkflowVariable,
WorkspaceExportPayload,
WorkspaceImportRequest,
WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
export {
createPaginationMeta,
DEFAULT_LIMIT,
extractWorkflowMetadata,
MAX_LIMIT,
parsePaginationParams,
parseWorkflowVariables,
toAdminFolder,
toAdminUser,
toAdminWorkflow,
toAdminWorkspace,
} from '@/app/api/v1/admin/types'

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

View File

@@ -0,0 +1,82 @@
/**
* Admin API Response Helpers
*
* Consistent response formatting for all Admin API endpoints.
*/
import { NextResponse } from 'next/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 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
)
}

View File

@@ -0,0 +1,402 @@
/**
* Admin API Types
*
* This file defines the types for the Admin API endpoints.
* All responses follow a consistent structure for predictability.
*/
import type { user, 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>
// =============================================================================
// 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 {
const limitParam = url.searchParams.get('limit')
const offsetParam = url.searchParams.get('offset')
let limit = limitParam ? Number.parseInt(limitParam, 10) : DEFAULT_LIMIT
let offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0
if (Number.isNaN(limit) || limit < 1) limit = DEFAULT_LIMIT
if (limit > MAX_LIMIT) limit = MAX_LIMIT
if (Number.isNaN(offset) || offset < 0) offset = 0
return { limit, offset }
}
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
color: string
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 function toAdminWorkflow(dbWorkflow: DbWorkflow): AdminWorkflow {
return {
id: dbWorkflow.id,
name: dbWorkflow.name,
description: dbWorkflow.description,
color: dbWorkflow.color,
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
color?: string
exportedAt?: string
}
variables?: WorkflowVariable[]
}
export interface WorkflowExportPayload {
version: '1.0'
exportedAt: string
workflow: {
id: string
name: string
description: string | null
color: string
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 array format.
* Handles both array and Record<string, Variable> formats.
*/
export function parseWorkflowVariables(
dbVariables: DbWorkflow['variables']
): WorkflowVariable[] | undefined {
if (!dbVariables) return undefined
try {
const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables
if (Array.isArray(varsObj)) {
return varsObj.map((v) => ({
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}))
}
if (typeof varsObj === 'object' && varsObj !== null) {
return Object.values(varsObj).map((v: unknown) => {
const variable = v as { id: string; name: string; type: VariableType; value: unknown }
return {
id: variable.id,
name: variable.name,
type: variable.type,
value: variable.value,
}
})
}
} 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; color: string; description: string } {
const defaults = {
name: overrideName || 'Imported Workflow',
color: '#3972F6',
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 color =
getNestedString(parsed, 'workflow.color') ||
getNestedString(parsed, 'state.metadata.color') ||
getNestedString(parsed, 'metadata.color') ||
defaults.color
const description =
getNestedString(parsed, 'workflow.description') ||
getNestedString(parsed, 'state.metadata.description') ||
getNestedString(parsed, 'metadata.description') ||
defaults.description
return { name, color, 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
}

View File

@@ -0,0 +1,46 @@
/**
* GET /api/v1/admin/users/[id]
*
* Get user details.
*
* Response: AdminSingleResponse<AdminUser>
*/
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
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 = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: userId } = await context.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')
}
})

View File

@@ -0,0 +1,49 @@
/**
* 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 { count } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminUser,
createPaginationMeta,
parsePaginationParams,
toAdminUser,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminUsersAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
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')
}
})

View File

@@ -0,0 +1,89 @@
/**
* GET /api/v1/admin/workflows/[id]/export
*
* Export a single workflow as JSON.
*
* Response: AdminSingleResponse<WorkflowExportPayload>
*/
import { db } from '@sim/db'
import { workflow } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
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 = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.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,
color: workflowData.color,
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,
color: workflowData.color,
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')
}
})

View File

@@ -0,0 +1,105 @@
/**
* 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 { workflow, workflowBlocks, workflowEdges, workflowSchedule } from '@sim/db/schema'
import { count, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
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 = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowData] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
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 = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
try {
const [workflowData] = await db
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowData) {
return notFoundResponse('Workflow')
}
await db.transaction(async (tx) => {
await Promise.all([
tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)),
tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)),
tx.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId)),
])
await tx.delete(workflow).where(eq(workflow.id, workflowId))
})
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')
}
})

View File

@@ -0,0 +1,153 @@
/**
* 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 { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import {
extractWorkflowMetadata,
type WorkflowImportRequest,
type WorkflowVariable,
} from '@/app/api/v1/admin/types'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
const logger = createLogger('AdminWorkflowImportAPI')
interface ImportSuccessResponse {
workflowId: string
name: string
success: true
}
export const POST = withAdminAuth(async (request) => {
try {
const body = (await request.json()) as WorkflowImportRequest
if (!body.workspaceId) {
return badRequestResponse('workspaceId is required')
}
if (!body.workflow) {
return badRequestResponse('workflow is required')
}
const { workspaceId, folderId, name: overrideName } = body
const [workspaceData] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.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,
color: workflowColor,
description: workflowDescription,
} = extractWorkflowMetadata(parsedWorkflow, overrideName)
const workflowId = crypto.randomUUID()
const now = new Date()
await db.insert(workflow).values({
id: workflowId,
userId: workspaceData.ownerId,
workspaceId,
folderId: folderId || null,
name: workflowName,
description: workflowDescription,
color: workflowColor,
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 && Array.isArray(workflowData.variables)) {
const variablesRecord: Record<string, WorkflowVariable> = {}
workflowData.variables.forEach((v) => {
const varId = v.id || crypto.randomUUID()
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))
}
logger.info(
`Admin API: Imported workflow ${workflowId} (${workflowName}) into workspace ${workspaceId}`
)
const response: ImportSuccessResponse = {
workflowId,
name: workflowName,
success: true,
}
return NextResponse.json(response)
} catch (error) {
logger.error('Admin API: Failed to import workflow', { error })
return internalErrorResponse('Failed to import workflow')
}
})

View File

@@ -0,0 +1,49 @@
/**
* 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 { count } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminWorkflow,
createPaginationMeta,
parsePaginationParams,
toAdminWorkflow,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkflowsAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
try {
const [countResult, workflows] = await Promise.all([
db.select({ total: count() }).from(workflow),
db.select().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')
}
})

View File

@@ -0,0 +1,164 @@
/**
* GET /api/v1/admin/workspaces/[id]/export
*
* Export an entire workspace as a ZIP file or JSON.
*
* Query Parameters:
* - format: 'zip' (default) or 'json'
*
* Response:
* - ZIP file download (Content-Type: application/zip)
* - JSON: WorkspaceExportPayload
*/
import { db } from '@sim/db'
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export'
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 {
type FolderExportPayload,
parseWorkflowVariables,
type WorkflowExportState,
type WorkspaceExportPayload,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceExportAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
const url = new URL(request.url)
const format = url.searchParams.get('format') || 'zip'
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,
color: wf.color,
exportedAt: new Date().toISOString(),
},
variables,
}
workflowExports.push({
workflow: {
id: wf.id,
name: wf.name,
description: wf.description,
color: wf.color,
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`
)
if (format === 'json') {
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,
color: wf.workflow.color ?? 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 = workspaceData.name.replace(/[^a-z0-9-_]/gi, '-')
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
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 workspace', { error, workspaceId })
return internalErrorResponse('Failed to export workspace')
}
})

View File

@@ -0,0 +1,75 @@
/**
* 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 { count, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
import {
type AdminFolder,
createPaginationMeta,
parsePaginationParams,
toAdminFolder,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceFoldersAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
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')
}
})

View File

@@ -0,0 +1,301 @@
/**
* 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, workspace } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractWorkflowName,
extractWorkflowsFromZip,
} from '@/lib/workflows/operations/import-export'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import {
extractWorkflowMetadata,
type ImportResult,
type WorkflowVariable,
type WorkspaceImportRequest,
type WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
const logger = createLogger('AdminWorkspaceImportAPI')
interface RouteParams {
id: string
}
interface ParsedWorkflow {
content: string
name: string
folderPath: string[]
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
const url = new URL(request.url)
const createFolders = url.searchParams.get('createFolders') !== 'false'
const rootFolderName = url.searchParams.get('rootFolderName')
try {
const [workspaceData] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const contentType = request.headers.get('content-type') || ''
let workflowsToImport: ParsedWorkflow[] = []
if (contentType.includes('application/json')) {
const body = (await request.json()) as WorkspaceImportRequest
if (!body.workflows || !Array.isArray(body.workflows)) {
return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
}
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 = crypto.randomUUID()
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 = crypto.randomUUID()
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 parsedContent = (() => {
try {
return JSON.parse(wf.content)
} catch {
return null
}
})()
const { color: workflowColor } = extractWorkflowMetadata(parsedContent)
const workflowId = crypto.randomUUID()
const now = new Date()
await db.insert(workflow).values({
id: workflowId,
userId: ownerId,
workspaceId,
folderId: targetFolderId,
name: workflowName,
description: workflowData.metadata?.description || 'Imported via Admin API',
color: workflowColor,
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: workflowName,
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 || crypto.randomUUID()
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: workflowName,
success: true,
}
} catch (error) {
return {
workflowId: '',
name: wf.name,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}

View File

@@ -0,0 +1,62 @@
/**
* 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 { count, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
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 = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.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')
}
})

View File

@@ -0,0 +1,129 @@
/**
* 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,
workflowBlocks,
workflowEdges,
workflowSchedule,
workspace,
} from '@sim/db/schema'
import { count, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
import {
type AdminWorkflow,
createPaginationMeta,
parsePaginationParams,
toAdminWorkflow,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspaceWorkflowsAPI')
interface RouteParams {
id: string
}
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
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, workflows] = await Promise.all([
db.select({ total: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)),
db
.select()
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
.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 = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workspaceId } = await context.params
try {
const [workspaceData] = await db
.select({ id: workspace.id })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return notFoundResponse('Workspace')
}
const workflowsToDelete = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.workspaceId, workspaceId))
if (workflowsToDelete.length === 0) {
return NextResponse.json({ success: true, deleted: 0 })
}
const workflowIds = workflowsToDelete.map((w) => w.id)
await db.transaction(async (tx) => {
await Promise.all([
tx.delete(workflowBlocks).where(inArray(workflowBlocks.workflowId, workflowIds)),
tx.delete(workflowEdges).where(inArray(workflowEdges.workflowId, workflowIds)),
tx.delete(workflowSchedule).where(inArray(workflowSchedule.workflowId, workflowIds)),
])
await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId))
})
logger.info(`Admin API: Deleted ${workflowIds.length} workflows from workspace ${workspaceId}`)
return NextResponse.json({ success: true, deleted: workflowIds.length })
} catch (error) {
logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId })
return internalErrorResponse('Failed to delete workflows')
}
})

View File

@@ -0,0 +1,49 @@
/**
* 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 { count } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
import {
type AdminWorkspace,
createPaginationMeta,
parsePaginationParams,
toAdminWorkspace,
} from '@/app/api/v1/admin/types'
const logger = createLogger('AdminWorkspacesAPI')
export const GET = withAdminAuth(async (request) => {
const url = new URL(request.url)
const { limit, offset } = parsePaginationParams(url)
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')
}
})

View File

@@ -108,6 +108,9 @@ export const env = createEnv({
BROWSERBASE_PROJECT_ID: z.string().min(1).optional(), // Browserbase project ID
GITHUB_TOKEN: z.string().optional(), // GitHub personal access token for API access
// Admin API
ADMIN_API_KEY: z.string().min(32).optional(), // Admin API key for self-hosted GitOps access (generate with: openssl rand -hex 32)
// Infrastructure & Deployment
NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment
DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment