From 530a3292a35e4fdca599a6bdd19212f3a65190f3 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 19 Jan 2026 15:10:47 -0800 Subject: [PATCH 1/5] feat(api): added workflows api route for dynamic discovery --- apps/sim/app/api/v1/workflows/[id]/route.ts | 138 +++++++++++++++ apps/sim/app/api/v1/workflows/route.ts | 183 ++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 apps/sim/app/api/v1/workflows/[id]/route.ts create mode 100644 apps/sim/app/api/v1/workflows/route.ts diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts new file mode 100644 index 0000000000..8d6c39cee6 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -0,0 +1,138 @@ +import { db } from '@sim/db' +import { permissions, workflow, workflowBlocks } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' + +const logger = createLogger('V1WorkflowDetailsAPI') + +export const revalidate = 0 + +interface InputField { + name: string + type: string + description?: string +} + +/** + * Extracts input fields from workflow blocks. + * Finds the starter/trigger block and extracts its inputFormat configuration. + */ +function extractInputFields(blocks: Array<{ type: string; subBlocks: unknown }>): InputField[] { + const starterBlock = blocks.find((block) => isValidStartBlockType(block.type)) + + if (!starterBlock) { + return [] + } + + const subBlocks = starterBlock.subBlocks as Record | undefined + const inputFormat = subBlocks?.inputFormat?.value + + if (!Array.isArray(inputFormat)) { + return [] + } + + return inputFormat + .filter( + (field: unknown): field is { name: string; type?: string; description?: string } => + typeof field === 'object' && + field !== null && + 'name' in field && + typeof (field as { name: unknown }).name === 'string' && + (field as { name: string }).name.trim() !== '' + ) + .map((field) => ({ + name: field.name, + type: field.type || 'string', + ...(field.description && { description: field.description }), + })) +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { id } = await params + + logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId }) + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + variables: workflow.variables, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(workflow.id, id)) + .limit(1) + + const workflowData = rows[0] + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + const blocks = await db + .select({ + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const inputs = extractInputFields(blocks) + + const response = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + color: workflowData.color, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() || null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() || null, + variables: workflowData.variables || {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } + + const limits = await getUserLimits(userId) + + const apiResponse = createApiResponse({ data: response }, limits, rateLimit) + + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Workflow details fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts new file mode 100644 index 0000000000..1dcdcba020 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -0,0 +1,183 @@ +import { db } from '@sim/db' +import { permissions, workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, asc, eq, gt, or } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' +import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' + +const logger = createLogger('V1WorkflowsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const QueryParamsSchema = z.object({ + workspaceId: z.string(), + folderId: z.string().optional(), + deployedOnly: z.coerce.boolean().optional().default(false), + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) + +interface CursorData { + sortOrder: number + createdAt: string + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +function decodeCursor(cursor: string): CursorData | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) + } catch { + return null + } +} + +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { searchParams } = new URL(request.url) + const rawParams = Object.fromEntries(searchParams.entries()) + + const validationResult = QueryParamsSchema.safeParse(rawParams) + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid parameters', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const params = validationResult.data + + logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, { + userId, + filters: { + folderId: params.folderId, + deployedOnly: params.deployedOnly, + }, + }) + + const conditions = [ + eq(workflow.workspaceId, params.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId), + eq(permissions.userId, userId), + ] + + if (params.folderId) { + conditions.push(eq(workflow.folderId, params.folderId)) + } + + if (params.deployedOnly) { + conditions.push(eq(workflow.isDeployed, true)) + } + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + conditions.push( + or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + )! + ) + } + } + + const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + color: workflow.color, + folderId: workflow.folderId, + workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(and(...conditions)) + .orderBy(...orderByClause) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | undefined + if (hasMore && data.length > 0) { + const lastWorkflow = data[data.length - 1] + nextCursor = encodeCursor({ + sortOrder: lastWorkflow.sortOrder, + createdAt: lastWorkflow.createdAt.toISOString(), + id: lastWorkflow.id, + }) + } + + const formattedWorkflows = data.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + color: w.color, + folderId: w.folderId, + workspaceId: w.workspaceId, + isDeployed: w.isDeployed, + deployedAt: w.deployedAt?.toISOString() || null, + runCount: w.runCount, + lastRunAt: w.lastRunAt?.toISOString() || null, + createdAt: w.createdAt.toISOString(), + updatedAt: w.updatedAt.toISOString(), + })) + + const limits = await getUserLimits(userId) + + const response = createApiResponse( + { + data: formattedWorkflows, + nextCursor, + }, + limits, + rateLimit + ) + + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Workflows fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} From a2c794a77edea9307896e83e2e08bdbbb16fbd4f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 19 Jan 2026 15:45:30 -0800 Subject: [PATCH 2/5] added ability to edit parameter and workflow descriptions --- apps/sim/app/api/v1/workflows/[id]/route.ts | 50 +--- .../deploy-modal/components/api/api.tsx | 33 --- .../general/components/api-info-modal.tsx | 262 ++++++++++++++++++ .../components/deploy-modal/deploy-modal.tsx | 21 +- apps/sim/lib/workflows/input-format.ts | 7 +- 5 files changed, 289 insertions(+), 84 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 8d6c39cee6..c836e936b8 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -3,7 +3,7 @@ import { permissions, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -11,46 +11,6 @@ const logger = createLogger('V1WorkflowDetailsAPI') export const revalidate = 0 -interface InputField { - name: string - type: string - description?: string -} - -/** - * Extracts input fields from workflow blocks. - * Finds the starter/trigger block and extracts its inputFormat configuration. - */ -function extractInputFields(blocks: Array<{ type: string; subBlocks: unknown }>): InputField[] { - const starterBlock = blocks.find((block) => isValidStartBlockType(block.type)) - - if (!starterBlock) { - return [] - } - - const subBlocks = starterBlock.subBlocks as Record | undefined - const inputFormat = subBlocks?.inputFormat?.value - - if (!Array.isArray(inputFormat)) { - return [] - } - - return inputFormat - .filter( - (field: unknown): field is { name: string; type?: string; description?: string } => - typeof field === 'object' && - field !== null && - 'name' in field && - typeof (field as { name: unknown }).name === 'string' && - (field as { name: string }).name.trim() !== '' - ) - .map((field) => ({ - name: field.name, - type: field.type || 'string', - ...(field.description && { description: field.description }), - })) -} - export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = crypto.randomUUID().slice(0, 8) @@ -98,15 +58,19 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - const blocks = await db + const blockRows = await db .select({ + id: workflowBlocks.id, type: workflowBlocks.type, subBlocks: workflowBlocks.subBlocks, }) .from(workflowBlocks) .where(eq(workflowBlocks.workflowId, id)) - const inputs = extractInputFields(blocks) + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) const response = { id: workflowData.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx index 97ddbbd770..eff1cff3a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx @@ -452,39 +452,6 @@ console.log(limits);` )} - {/*
-
- - - - - - - {copied.endpoint ? 'Copied' : 'Copy'} - - -
- -
*/} -