mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(admin): added admin APIs for admin management (#2206)
This commit is contained in:
@@ -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
|
||||
|
||||
79
apps/sim/app/api/v1/admin/auth.ts
Normal file
79
apps/sim/app/api/v1/admin/auth.ts
Normal 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)
|
||||
}
|
||||
79
apps/sim/app/api/v1/admin/index.ts
Normal file
79
apps/sim/app/api/v1/admin/index.ts
Normal 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'
|
||||
50
apps/sim/app/api/v1/admin/middleware.ts
Normal file
50
apps/sim/app/api/v1/admin/middleware.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
82
apps/sim/app/api/v1/admin/responses.ts
Normal file
82
apps/sim/app/api/v1/admin/responses.ts
Normal 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
|
||||
)
|
||||
}
|
||||
402
apps/sim/app/api/v1/admin/types.ts
Normal file
402
apps/sim/app/api/v1/admin/types.ts
Normal 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
|
||||
}
|
||||
46
apps/sim/app/api/v1/admin/users/[id]/route.ts
Normal file
46
apps/sim/app/api/v1/admin/users/[id]/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
49
apps/sim/app/api/v1/admin/users/route.ts
Normal file
49
apps/sim/app/api/v1/admin/users/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
89
apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts
Normal file
89
apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
105
apps/sim/app/api/v1/admin/workflows/[id]/route.ts
Normal file
105
apps/sim/app/api/v1/admin/workflows/[id]/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
153
apps/sim/app/api/v1/admin/workflows/import/route.ts
Normal file
153
apps/sim/app/api/v1/admin/workflows/import/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
49
apps/sim/app/api/v1/admin/workflows/route.ts
Normal file
49
apps/sim/app/api/v1/admin/workflows/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
164
apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts
Normal file
164
apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
75
apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts
Normal file
75
apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
301
apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
Normal file
301
apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
}
|
||||
62
apps/sim/app/api/v1/admin/workspaces/[id]/route.ts
Normal file
62
apps/sim/app/api/v1/admin/workspaces/[id]/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
129
apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts
Normal file
129
apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
49
apps/sim/app/api/v1/admin/workspaces/route.ts
Normal file
49
apps/sim/app/api/v1/admin/workspaces/route.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user