diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 4f0eac4ad9..06b4109433 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -19,7 +19,7 @@ export interface RateLimitResult { export async function checkRateLimit( request: NextRequest, - endpoint: 'logs' | 'logs-detail' = 'logs' + endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs' ): Promise { try { const auth = await authenticateV1Request(request) 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..658a0f8ea4 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -0,0 +1,102 @@ +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 { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +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 + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'workflow-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 blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) + + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) + + const response = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + 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..23bb707f15 --- /dev/null +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -0,0 +1,184 @@ +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, 'workflows') + 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) { + const cursorCondition = or( + gt(workflow.sortOrder, cursorData.sortOrder), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + gt(workflow.createdAt, new Date(cursorData.createdAt)) + ), + and( + eq(workflow.sortOrder, cursorData.sortOrder), + eq(workflow.createdAt, new Date(cursorData.createdAt)), + gt(workflow.id, cursorData.id) + ) + ) + if (cursorCondition) { + conditions.push(cursorCondition) + } + } + } + + const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] + + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + 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 }) + } +} 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'} - - -
- -
*/} -