Skip to content

Commit 6cbadd7

Browse files
authored
feat(api): added workflows api route for dynamic discovery (#2892)
* feat(api): added workflows api route for dynamic discovery * added ability to edit parameter and workflow descriptions * added new rate limit category, ack PR comments * fix hasChanges logic * added whitespace trimming before hasChanges check
1 parent 9efd3d5 commit 6cbadd7

File tree

7 files changed

+567
-42
lines changed

7 files changed

+567
-42
lines changed

apps/sim/app/api/v1/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface RateLimitResult {
1919

2020
export async function checkRateLimit(
2121
request: NextRequest,
22-
endpoint: 'logs' | 'logs-detail' = 'logs'
22+
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
2323
): Promise<RateLimitResult> {
2424
try {
2525
const auth = await authenticateV1Request(request)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { db } from '@sim/db'
2+
import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
7+
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
8+
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
9+
10+
const logger = createLogger('V1WorkflowDetailsAPI')
11+
12+
export const revalidate = 0
13+
14+
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
15+
const requestId = crypto.randomUUID().slice(0, 8)
16+
17+
try {
18+
const rateLimit = await checkRateLimit(request, 'workflow-detail')
19+
if (!rateLimit.allowed) {
20+
return createRateLimitResponse(rateLimit)
21+
}
22+
23+
const userId = rateLimit.userId!
24+
const { id } = await params
25+
26+
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
27+
28+
const rows = await db
29+
.select({
30+
id: workflow.id,
31+
name: workflow.name,
32+
description: workflow.description,
33+
color: workflow.color,
34+
folderId: workflow.folderId,
35+
workspaceId: workflow.workspaceId,
36+
isDeployed: workflow.isDeployed,
37+
deployedAt: workflow.deployedAt,
38+
runCount: workflow.runCount,
39+
lastRunAt: workflow.lastRunAt,
40+
variables: workflow.variables,
41+
createdAt: workflow.createdAt,
42+
updatedAt: workflow.updatedAt,
43+
})
44+
.from(workflow)
45+
.innerJoin(
46+
permissions,
47+
and(
48+
eq(permissions.entityType, 'workspace'),
49+
eq(permissions.entityId, workflow.workspaceId),
50+
eq(permissions.userId, userId)
51+
)
52+
)
53+
.where(eq(workflow.id, id))
54+
.limit(1)
55+
56+
const workflowData = rows[0]
57+
if (!workflowData) {
58+
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
59+
}
60+
61+
const blockRows = await db
62+
.select({
63+
id: workflowBlocks.id,
64+
type: workflowBlocks.type,
65+
subBlocks: workflowBlocks.subBlocks,
66+
})
67+
.from(workflowBlocks)
68+
.where(eq(workflowBlocks.workflowId, id))
69+
70+
const blocksRecord = Object.fromEntries(
71+
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
72+
)
73+
const inputs = extractInputFieldsFromBlocks(blocksRecord)
74+
75+
const response = {
76+
id: workflowData.id,
77+
name: workflowData.name,
78+
description: workflowData.description,
79+
color: workflowData.color,
80+
folderId: workflowData.folderId,
81+
workspaceId: workflowData.workspaceId,
82+
isDeployed: workflowData.isDeployed,
83+
deployedAt: workflowData.deployedAt?.toISOString() || null,
84+
runCount: workflowData.runCount,
85+
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
86+
variables: workflowData.variables || {},
87+
inputs,
88+
createdAt: workflowData.createdAt.toISOString(),
89+
updatedAt: workflowData.updatedAt.toISOString(),
90+
}
91+
92+
const limits = await getUserLimits(userId)
93+
94+
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
95+
96+
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
97+
} catch (error: unknown) {
98+
const message = error instanceof Error ? error.message : 'Unknown error'
99+
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
100+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
101+
}
102+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { db } from '@sim/db'
2+
import { permissions, workflow } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, asc, eq, gt, or } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
8+
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
9+
10+
const logger = createLogger('V1WorkflowsAPI')
11+
12+
export const dynamic = 'force-dynamic'
13+
export const revalidate = 0
14+
15+
const QueryParamsSchema = z.object({
16+
workspaceId: z.string(),
17+
folderId: z.string().optional(),
18+
deployedOnly: z.coerce.boolean().optional().default(false),
19+
limit: z.coerce.number().min(1).max(100).optional().default(50),
20+
cursor: z.string().optional(),
21+
})
22+
23+
interface CursorData {
24+
sortOrder: number
25+
createdAt: string
26+
id: string
27+
}
28+
29+
function encodeCursor(data: CursorData): string {
30+
return Buffer.from(JSON.stringify(data)).toString('base64')
31+
}
32+
33+
function decodeCursor(cursor: string): CursorData | null {
34+
try {
35+
return JSON.parse(Buffer.from(cursor, 'base64').toString())
36+
} catch {
37+
return null
38+
}
39+
}
40+
41+
export async function GET(request: NextRequest) {
42+
const requestId = crypto.randomUUID().slice(0, 8)
43+
44+
try {
45+
const rateLimit = await checkRateLimit(request, 'workflows')
46+
if (!rateLimit.allowed) {
47+
return createRateLimitResponse(rateLimit)
48+
}
49+
50+
const userId = rateLimit.userId!
51+
const { searchParams } = new URL(request.url)
52+
const rawParams = Object.fromEntries(searchParams.entries())
53+
54+
const validationResult = QueryParamsSchema.safeParse(rawParams)
55+
if (!validationResult.success) {
56+
return NextResponse.json(
57+
{ error: 'Invalid parameters', details: validationResult.error.errors },
58+
{ status: 400 }
59+
)
60+
}
61+
62+
const params = validationResult.data
63+
64+
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
65+
userId,
66+
filters: {
67+
folderId: params.folderId,
68+
deployedOnly: params.deployedOnly,
69+
},
70+
})
71+
72+
const conditions = [
73+
eq(workflow.workspaceId, params.workspaceId),
74+
eq(permissions.entityType, 'workspace'),
75+
eq(permissions.entityId, params.workspaceId),
76+
eq(permissions.userId, userId),
77+
]
78+
79+
if (params.folderId) {
80+
conditions.push(eq(workflow.folderId, params.folderId))
81+
}
82+
83+
if (params.deployedOnly) {
84+
conditions.push(eq(workflow.isDeployed, true))
85+
}
86+
87+
if (params.cursor) {
88+
const cursorData = decodeCursor(params.cursor)
89+
if (cursorData) {
90+
const cursorCondition = or(
91+
gt(workflow.sortOrder, cursorData.sortOrder),
92+
and(
93+
eq(workflow.sortOrder, cursorData.sortOrder),
94+
gt(workflow.createdAt, new Date(cursorData.createdAt))
95+
),
96+
and(
97+
eq(workflow.sortOrder, cursorData.sortOrder),
98+
eq(workflow.createdAt, new Date(cursorData.createdAt)),
99+
gt(workflow.id, cursorData.id)
100+
)
101+
)
102+
if (cursorCondition) {
103+
conditions.push(cursorCondition)
104+
}
105+
}
106+
}
107+
108+
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
109+
110+
const rows = await db
111+
.select({
112+
id: workflow.id,
113+
name: workflow.name,
114+
description: workflow.description,
115+
color: workflow.color,
116+
folderId: workflow.folderId,
117+
workspaceId: workflow.workspaceId,
118+
isDeployed: workflow.isDeployed,
119+
deployedAt: workflow.deployedAt,
120+
runCount: workflow.runCount,
121+
lastRunAt: workflow.lastRunAt,
122+
sortOrder: workflow.sortOrder,
123+
createdAt: workflow.createdAt,
124+
updatedAt: workflow.updatedAt,
125+
})
126+
.from(workflow)
127+
.innerJoin(
128+
permissions,
129+
and(
130+
eq(permissions.entityType, 'workspace'),
131+
eq(permissions.entityId, params.workspaceId),
132+
eq(permissions.userId, userId)
133+
)
134+
)
135+
.where(and(...conditions))
136+
.orderBy(...orderByClause)
137+
.limit(params.limit + 1)
138+
139+
const hasMore = rows.length > params.limit
140+
const data = rows.slice(0, params.limit)
141+
142+
let nextCursor: string | undefined
143+
if (hasMore && data.length > 0) {
144+
const lastWorkflow = data[data.length - 1]
145+
nextCursor = encodeCursor({
146+
sortOrder: lastWorkflow.sortOrder,
147+
createdAt: lastWorkflow.createdAt.toISOString(),
148+
id: lastWorkflow.id,
149+
})
150+
}
151+
152+
const formattedWorkflows = data.map((w) => ({
153+
id: w.id,
154+
name: w.name,
155+
description: w.description,
156+
color: w.color,
157+
folderId: w.folderId,
158+
workspaceId: w.workspaceId,
159+
isDeployed: w.isDeployed,
160+
deployedAt: w.deployedAt?.toISOString() || null,
161+
runCount: w.runCount,
162+
lastRunAt: w.lastRunAt?.toISOString() || null,
163+
createdAt: w.createdAt.toISOString(),
164+
updatedAt: w.updatedAt.toISOString(),
165+
}))
166+
167+
const limits = await getUserLimits(userId)
168+
169+
const response = createApiResponse(
170+
{
171+
data: formattedWorkflows,
172+
nextCursor,
173+
},
174+
limits,
175+
rateLimit
176+
)
177+
178+
return NextResponse.json(response.body, { headers: response.headers })
179+
} catch (error: unknown) {
180+
const message = error instanceof Error ? error.message : 'Unknown error'
181+
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
182+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
183+
}
184+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -452,39 +452,6 @@ console.log(limits);`
452452
</div>
453453
)}
454454

455-
{/* <div>
456-
<div className='mb-[6.5px] flex items-center justify-between'>
457-
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
458-
URL
459-
</Label>
460-
<Tooltip.Root>
461-
<Tooltip.Trigger asChild>
462-
<Button
463-
variant='ghost'
464-
onClick={() => handleCopy('endpoint', info.endpoint)}
465-
aria-label='Copy endpoint'
466-
className='!p-1.5 -my-1.5'
467-
>
468-
{copied.endpoint ? (
469-
<Check className='h-3 w-3' />
470-
) : (
471-
<Clipboard className='h-3 w-3' />
472-
)}
473-
</Button>
474-
</Tooltip.Trigger>
475-
<Tooltip.Content>
476-
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
477-
</Tooltip.Content>
478-
</Tooltip.Root>
479-
</div>
480-
<Code.Viewer
481-
code={info.endpoint}
482-
language='javascript'
483-
wrapText
484-
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
485-
/>
486-
</div> */}
487-
488455
<div>
489456
<div className='mb-[6.5px] flex items-center justify-between'>
490457
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>

0 commit comments

Comments
 (0)