Skip to content

Commit 530a329

Browse files
committed
feat(api): added workflows api route for dynamic discovery
1 parent 81cbfe7 commit 530a329

File tree

2 files changed

+321
-0
lines changed

2 files changed

+321
-0
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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 { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
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+
interface InputField {
15+
name: string
16+
type: string
17+
description?: string
18+
}
19+
20+
/**
21+
* Extracts input fields from workflow blocks.
22+
* Finds the starter/trigger block and extracts its inputFormat configuration.
23+
*/
24+
function extractInputFields(blocks: Array<{ type: string; subBlocks: unknown }>): InputField[] {
25+
const starterBlock = blocks.find((block) => isValidStartBlockType(block.type))
26+
27+
if (!starterBlock) {
28+
return []
29+
}
30+
31+
const subBlocks = starterBlock.subBlocks as Record<string, { value?: unknown }> | undefined
32+
const inputFormat = subBlocks?.inputFormat?.value
33+
34+
if (!Array.isArray(inputFormat)) {
35+
return []
36+
}
37+
38+
return inputFormat
39+
.filter(
40+
(field: unknown): field is { name: string; type?: string; description?: string } =>
41+
typeof field === 'object' &&
42+
field !== null &&
43+
'name' in field &&
44+
typeof (field as { name: unknown }).name === 'string' &&
45+
(field as { name: string }).name.trim() !== ''
46+
)
47+
.map((field) => ({
48+
name: field.name,
49+
type: field.type || 'string',
50+
...(field.description && { description: field.description }),
51+
}))
52+
}
53+
54+
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
55+
const requestId = crypto.randomUUID().slice(0, 8)
56+
57+
try {
58+
const rateLimit = await checkRateLimit(request, 'logs-detail')
59+
if (!rateLimit.allowed) {
60+
return createRateLimitResponse(rateLimit)
61+
}
62+
63+
const userId = rateLimit.userId!
64+
const { id } = await params
65+
66+
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
67+
68+
const rows = await db
69+
.select({
70+
id: workflow.id,
71+
name: workflow.name,
72+
description: workflow.description,
73+
color: workflow.color,
74+
folderId: workflow.folderId,
75+
workspaceId: workflow.workspaceId,
76+
isDeployed: workflow.isDeployed,
77+
deployedAt: workflow.deployedAt,
78+
runCount: workflow.runCount,
79+
lastRunAt: workflow.lastRunAt,
80+
variables: workflow.variables,
81+
createdAt: workflow.createdAt,
82+
updatedAt: workflow.updatedAt,
83+
})
84+
.from(workflow)
85+
.innerJoin(
86+
permissions,
87+
and(
88+
eq(permissions.entityType, 'workspace'),
89+
eq(permissions.entityId, workflow.workspaceId),
90+
eq(permissions.userId, userId)
91+
)
92+
)
93+
.where(eq(workflow.id, id))
94+
.limit(1)
95+
96+
const workflowData = rows[0]
97+
if (!workflowData) {
98+
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
99+
}
100+
101+
const blocks = await db
102+
.select({
103+
type: workflowBlocks.type,
104+
subBlocks: workflowBlocks.subBlocks,
105+
})
106+
.from(workflowBlocks)
107+
.where(eq(workflowBlocks.workflowId, id))
108+
109+
const inputs = extractInputFields(blocks)
110+
111+
const response = {
112+
id: workflowData.id,
113+
name: workflowData.name,
114+
description: workflowData.description,
115+
color: workflowData.color,
116+
folderId: workflowData.folderId,
117+
workspaceId: workflowData.workspaceId,
118+
isDeployed: workflowData.isDeployed,
119+
deployedAt: workflowData.deployedAt?.toISOString() || null,
120+
runCount: workflowData.runCount,
121+
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
122+
variables: workflowData.variables || {},
123+
inputs,
124+
createdAt: workflowData.createdAt.toISOString(),
125+
updatedAt: workflowData.updatedAt.toISOString(),
126+
}
127+
128+
const limits = await getUserLimits(userId)
129+
130+
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
131+
132+
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
133+
} catch (error: unknown) {
134+
const message = error instanceof Error ? error.message : 'Unknown error'
135+
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
136+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
137+
}
138+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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, 'logs')
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+
conditions.push(
91+
or(
92+
gt(workflow.sortOrder, cursorData.sortOrder),
93+
and(
94+
eq(workflow.sortOrder, cursorData.sortOrder),
95+
gt(workflow.createdAt, new Date(cursorData.createdAt))
96+
),
97+
and(
98+
eq(workflow.sortOrder, cursorData.sortOrder),
99+
eq(workflow.createdAt, new Date(cursorData.createdAt)),
100+
gt(workflow.id, cursorData.id)
101+
)
102+
)!
103+
)
104+
}
105+
}
106+
107+
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
108+
109+
const rows = await db
110+
.select({
111+
id: workflow.id,
112+
name: workflow.name,
113+
description: workflow.description,
114+
color: workflow.color,
115+
folderId: workflow.folderId,
116+
workspaceId: workflow.workspaceId,
117+
isDeployed: workflow.isDeployed,
118+
deployedAt: workflow.deployedAt,
119+
runCount: workflow.runCount,
120+
lastRunAt: workflow.lastRunAt,
121+
sortOrder: workflow.sortOrder,
122+
createdAt: workflow.createdAt,
123+
updatedAt: workflow.updatedAt,
124+
})
125+
.from(workflow)
126+
.innerJoin(
127+
permissions,
128+
and(
129+
eq(permissions.entityType, 'workspace'),
130+
eq(permissions.entityId, params.workspaceId),
131+
eq(permissions.userId, userId)
132+
)
133+
)
134+
.where(and(...conditions))
135+
.orderBy(...orderByClause)
136+
.limit(params.limit + 1)
137+
138+
const hasMore = rows.length > params.limit
139+
const data = rows.slice(0, params.limit)
140+
141+
let nextCursor: string | undefined
142+
if (hasMore && data.length > 0) {
143+
const lastWorkflow = data[data.length - 1]
144+
nextCursor = encodeCursor({
145+
sortOrder: lastWorkflow.sortOrder,
146+
createdAt: lastWorkflow.createdAt.toISOString(),
147+
id: lastWorkflow.id,
148+
})
149+
}
150+
151+
const formattedWorkflows = data.map((w) => ({
152+
id: w.id,
153+
name: w.name,
154+
description: w.description,
155+
color: w.color,
156+
folderId: w.folderId,
157+
workspaceId: w.workspaceId,
158+
isDeployed: w.isDeployed,
159+
deployedAt: w.deployedAt?.toISOString() || null,
160+
runCount: w.runCount,
161+
lastRunAt: w.lastRunAt?.toISOString() || null,
162+
createdAt: w.createdAt.toISOString(),
163+
updatedAt: w.updatedAt.toISOString(),
164+
}))
165+
166+
const limits = await getUserLimits(userId)
167+
168+
const response = createApiResponse(
169+
{
170+
data: formattedWorkflows,
171+
nextCursor,
172+
},
173+
limits,
174+
rateLimit
175+
)
176+
177+
return NextResponse.json(response.body, { headers: response.headers })
178+
} catch (error: unknown) {
179+
const message = error instanceof Error ? error.message : 'Unknown error'
180+
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
181+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
182+
}
183+
}

0 commit comments

Comments
 (0)