diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 8e845f6b2a..7c6ca476da 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -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 diff --git a/apps/sim/app/api/v1/admin/auth.ts b/apps/sim/app/api/v1/admin/auth.ts new file mode 100644 index 0000000000..642968b996 --- /dev/null +++ b/apps/sim/app/api/v1/admin/auth.ts @@ -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) +} diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts new file mode 100644 index 0000000000..6dea03e348 --- /dev/null +++ b/apps/sim/app/api/v1/admin/index.ts @@ -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' diff --git a/apps/sim/app/api/v1/admin/middleware.ts b/apps/sim/app/api/v1/admin/middleware.ts new file mode 100644 index 0000000000..9408f90597 --- /dev/null +++ b/apps/sim/app/api/v1/admin/middleware.ts @@ -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 + +export type AdminRouteHandlerWithParams = ( + request: NextRequest, + context: { params: Promise } +) => Promise + +/** + * 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( + handler: AdminRouteHandlerWithParams +): AdminRouteHandlerWithParams { + return async (request: NextRequest, context: { params: Promise }) => { + const auth = authenticateAdminRequest(request) + + if (!auth.authenticated) { + if (auth.notConfigured) { + return notConfiguredResponse() + } + return unauthorizedResponse(auth.error) + } + + return handler(request, context) + } +} diff --git a/apps/sim/app/api/v1/admin/responses.ts b/apps/sim/app/api/v1/admin/responses.ts new file mode 100644 index 0000000000..dfbfa85488 --- /dev/null +++ b/apps/sim/app/api/v1/admin/responses.ts @@ -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( + data: T[], + pagination: PaginationMeta +): NextResponse> { + return NextResponse.json({ data, pagination }) +} + +/** + * Create a successful single resource response + */ +export function singleResponse(data: T): NextResponse> { + return NextResponse.json({ data }) +} + +/** + * Create an error response + */ +export function errorResponse( + code: string, + message: string, + status: number, + details?: unknown +): NextResponse { + 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 + ) +} diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts new file mode 100644 index 0000000000..fbac0aa1c8 --- /dev/null +++ b/apps/sim/app/api/v1/admin/types.ts @@ -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 +export type DbWorkspace = InferSelectModel +export type DbWorkflow = InferSelectModel +export type DbWorkflowFolder = InferSelectModel + +// ============================================================================= +// 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 { + data: T[] + pagination: PaginationMeta +} + +export interface AdminSingleResponse { + 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 + edges: Edge[] + loops: Record + parallels: Record + 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 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 + + 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, 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)[part] + } + + return typeof current === 'string' ? current : undefined +} diff --git a/apps/sim/app/api/v1/admin/users/[id]/route.ts b/apps/sim/app/api/v1/admin/users/[id]/route.ts new file mode 100644 index 0000000000..e1b52c7e9b --- /dev/null +++ b/apps/sim/app/api/v1/admin/users/[id]/route.ts @@ -0,0 +1,46 @@ +/** + * GET /api/v1/admin/users/[id] + * + * Get user details. + * + * Response: AdminSingleResponse + */ + +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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/users/route.ts b/apps/sim/app/api/v1/admin/users/route.ts new file mode 100644 index 0000000000..698d75808c --- /dev/null +++ b/apps/sim/app/api/v1/admin/users/route.ts @@ -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 + */ + +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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts new file mode 100644 index 0000000000..7aa6ad503e --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts @@ -0,0 +1,89 @@ +/** + * GET /api/v1/admin/workflows/[id]/export + * + * Export a single workflow as JSON. + * + * Response: AdminSingleResponse + */ + +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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts new file mode 100644 index 0000000000..8aae98af42 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -0,0 +1,105 @@ +/** + * GET /api/v1/admin/workflows/[id] + * + * Get workflow details including block and edge counts. + * + * Response: AdminSingleResponse + * + * 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(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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts new file mode 100644 index 0000000000..9dc00e5d0e --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -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 = {} + 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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workflows/route.ts b/apps/sim/app/api/v1/admin/workflows/route.ts new file mode 100644 index 0000000000..3c190330a2 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workflows/route.ts @@ -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 + */ + +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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts new file mode 100644 index 0000000000..a943cfa7a3 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts new file mode 100644 index 0000000000..a484643d1a --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -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 + */ + +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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts new file mode 100644 index 0000000000..11989448ec --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -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(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() + 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 +): Promise { + 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 = {} + 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', + } + } +} diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts new file mode 100644 index 0000000000..c9dd07a237 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -0,0 +1,62 @@ +/** + * GET /api/v1/admin/workspaces/[id] + * + * Get workspace details including workflow and folder counts. + * + * Response: AdminSingleResponse + */ + +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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts new file mode 100644 index 0000000000..867f5f2a7b --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts @@ -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 + * + * 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(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(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') + } +}) diff --git a/apps/sim/app/api/v1/admin/workspaces/route.ts b/apps/sim/app/api/v1/admin/workspaces/route.ts new file mode 100644 index 0000000000..1f3fe3e197 --- /dev/null +++ b/apps/sim/app/api/v1/admin/workspaces/route.ts @@ -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 + */ + +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') + } +}) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index dd94c92d61..ac5ec6ffe0 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -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