mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* feat(analytics): add PostHog product analytics * fix(posthog): fix workspace group via URL params, type errors, and clean up comments * fix(posthog): address PR review - fix pre-tx event, auth_method, paused executions, enterprise cancellation, settings double-fire * chore(posthog): remove unused identifyServerPerson * fix(posthog): isolate processQueuedResumes errors, simplify settings posthog deps * fix(posthog): correctly classify SSO auth_method, fix phantom empty-string workspace groups * fix(posthog): remove usePostHog from memo'd TemplateCard, fix copilot chat phantom workspace group * fix(posthog): eliminate all remaining phantom empty-string workspace groups * fix(posthog): fix cancel route phantom group, remove redundant workspaceId shadow in catch block * fix(posthog): use ids.length for block_removed guard to handle container blocks with descendants * chore(posthog): remove unused removedBlockTypes variable * fix(posthog): remove phantom $set person properties from subscription events * fix(posthog): add passedKnowledgeBaseName to knowledge_base_opened effect deps * fix(posthog): capture currentWorkflowId synchronously before async import to avoid stale closure * fix(posthog): add typed captureEvent wrapper for React components, deduplicate copilot_panel_opened * feat(posthog): add task_created and task_message_sent events, remove copilot_panel_opened * feat(posthog): track task_renamed, task_deleted, task_marked_read, task_marked_unread * feat(analytics): expand posthog event coverage with source tracking and lifecycle events * fix(analytics): flush posthog events on SIGTERM before ECS task termination * fix(analytics): fix posthog in useCallback deps and fire block events for bulk operations
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { permissions, workflow, workflowFolder } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
|
import { generateRequestId } from '@/lib/core/utils/request'
|
|
import { captureServerEvent } from '@/lib/posthog/server'
|
|
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
|
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
|
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
|
import { deduplicateWorkflowName, listWorkflows, type WorkflowScope } from '@/lib/workflows/utils'
|
|
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
|
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
|
|
|
const logger = createLogger('WorkflowAPI')
|
|
|
|
const CreateWorkflowSchema = z.object({
|
|
id: z.string().uuid().optional(),
|
|
name: z.string().min(1, 'Name is required'),
|
|
description: z.string().optional().default(''),
|
|
color: z
|
|
.string()
|
|
.optional()
|
|
.transform((c) => c || getNextWorkflowColor()),
|
|
workspaceId: z.string().optional(),
|
|
folderId: z.string().nullable().optional(),
|
|
sortOrder: z.number().int().optional(),
|
|
deduplicate: z.boolean().optional(),
|
|
})
|
|
|
|
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
|
export async function GET(request: NextRequest) {
|
|
const requestId = generateRequestId()
|
|
const startTime = Date.now()
|
|
const url = new URL(request.url)
|
|
const workspaceId = url.searchParams.get('workspaceId')
|
|
const scope = (url.searchParams.get('scope') ?? 'active') as WorkflowScope
|
|
|
|
try {
|
|
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
|
if (!auth.success || !auth.userId) {
|
|
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
const userId = auth.userId
|
|
|
|
if (workspaceId) {
|
|
const wsExists = await workspaceExists(workspaceId)
|
|
|
|
if (!wsExists) {
|
|
logger.warn(
|
|
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
|
|
)
|
|
return NextResponse.json(
|
|
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
|
|
|
|
if (!userRole) {
|
|
logger.warn(
|
|
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
|
|
)
|
|
return NextResponse.json(
|
|
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
}
|
|
|
|
if (!['active', 'archived', 'all'].includes(scope)) {
|
|
return NextResponse.json({ error: 'Invalid scope' }, { status: 400 })
|
|
}
|
|
|
|
let workflows
|
|
|
|
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
|
|
|
if (workspaceId) {
|
|
workflows = await listWorkflows(workspaceId, { scope })
|
|
} else {
|
|
const workspacePermissionRows = await db
|
|
.select({ workspaceId: permissions.entityId })
|
|
.from(permissions)
|
|
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
|
|
const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId)
|
|
if (workspaceIds.length === 0) {
|
|
return NextResponse.json({ data: [] }, { status: 200 })
|
|
}
|
|
workflows = await db
|
|
.select()
|
|
.from(workflow)
|
|
.where(
|
|
scope === 'all'
|
|
? inArray(workflow.workspaceId, workspaceIds)
|
|
: scope === 'archived'
|
|
? and(
|
|
inArray(workflow.workspaceId, workspaceIds),
|
|
sql`${workflow.archivedAt} IS NOT NULL`
|
|
)
|
|
: and(inArray(workflow.workspaceId, workspaceIds), isNull(workflow.archivedAt))
|
|
)
|
|
.orderBy(...orderByClause)
|
|
}
|
|
|
|
return NextResponse.json({ data: workflows }, { status: 200 })
|
|
} catch (error: any) {
|
|
const elapsed = Date.now() - startTime
|
|
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// POST /api/workflows - Create a new workflow
|
|
export async function POST(req: NextRequest) {
|
|
const requestId = generateRequestId()
|
|
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
|
if (!auth.success || !auth.userId) {
|
|
logger.warn(`[${requestId}] Unauthorized workflow creation attempt`)
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
const userId = auth.userId
|
|
|
|
try {
|
|
const body = await req.json()
|
|
const {
|
|
id: clientId,
|
|
name: requestedName,
|
|
description,
|
|
color,
|
|
workspaceId,
|
|
folderId,
|
|
sortOrder: providedSortOrder,
|
|
deduplicate,
|
|
} = CreateWorkflowSchema.parse(body)
|
|
|
|
if (!workspaceId) {
|
|
logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`)
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
'workspaceId is required. Personal workflows are deprecated and cannot be created.',
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
const workspacePermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
|
|
|
if (!workspacePermission || workspacePermission === 'read') {
|
|
logger.warn(
|
|
`[${requestId}] User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions`
|
|
)
|
|
return NextResponse.json(
|
|
{ error: 'Write or Admin access required to create workflows in this workspace' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
|
|
const workflowId = clientId || crypto.randomUUID()
|
|
const now = new Date()
|
|
|
|
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
|
|
|
|
let sortOrder: number
|
|
if (providedSortOrder !== undefined) {
|
|
sortOrder = providedSortOrder
|
|
} else {
|
|
const workflowParentCondition = folderId
|
|
? eq(workflow.folderId, folderId)
|
|
: isNull(workflow.folderId)
|
|
const folderParentCondition = folderId
|
|
? eq(workflowFolder.parentId, folderId)
|
|
: isNull(workflowFolder.parentId)
|
|
|
|
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
|
|
db
|
|
.select({ minOrder: min(workflow.sortOrder) })
|
|
.from(workflow)
|
|
.where(
|
|
and(
|
|
eq(workflow.workspaceId, workspaceId),
|
|
workflowParentCondition,
|
|
isNull(workflow.archivedAt)
|
|
)
|
|
),
|
|
db
|
|
.select({ minOrder: min(workflowFolder.sortOrder) })
|
|
.from(workflowFolder)
|
|
.where(and(eq(workflowFolder.workspaceId, workspaceId), folderParentCondition)),
|
|
])
|
|
|
|
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
|
|
number | null
|
|
>((currentMin, candidate) => {
|
|
if (candidate == null) return currentMin
|
|
if (currentMin == null) return candidate
|
|
return Math.min(currentMin, candidate)
|
|
}, null)
|
|
|
|
sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
|
}
|
|
|
|
let name = requestedName
|
|
|
|
if (deduplicate) {
|
|
name = await deduplicateWorkflowName(requestedName, workspaceId, folderId)
|
|
} else {
|
|
const duplicateConditions = [
|
|
eq(workflow.workspaceId, workspaceId),
|
|
isNull(workflow.archivedAt),
|
|
eq(workflow.name, requestedName),
|
|
]
|
|
|
|
if (folderId) {
|
|
duplicateConditions.push(eq(workflow.folderId, folderId))
|
|
} else {
|
|
duplicateConditions.push(isNull(workflow.folderId))
|
|
}
|
|
|
|
const [duplicateWorkflow] = await db
|
|
.select({ id: workflow.id })
|
|
.from(workflow)
|
|
.where(and(...duplicateConditions))
|
|
.limit(1)
|
|
|
|
if (duplicateWorkflow) {
|
|
return NextResponse.json(
|
|
{ error: `A workflow named "${requestedName}" already exists in this folder` },
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
}
|
|
|
|
import('@/lib/core/telemetry')
|
|
.then(({ PlatformEvents }) => {
|
|
PlatformEvents.workflowCreated({
|
|
workflowId,
|
|
name,
|
|
workspaceId: workspaceId || undefined,
|
|
folderId: folderId || undefined,
|
|
})
|
|
})
|
|
.catch(() => {
|
|
// Silently fail
|
|
})
|
|
|
|
const { workflowState, subBlockValues, startBlockId } = buildDefaultWorkflowArtifacts()
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(workflow).values({
|
|
id: workflowId,
|
|
userId,
|
|
workspaceId,
|
|
folderId: folderId || null,
|
|
sortOrder,
|
|
name,
|
|
description,
|
|
color,
|
|
lastSynced: now,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
isDeployed: false,
|
|
runCount: 0,
|
|
variables: {},
|
|
})
|
|
|
|
await saveWorkflowToNormalizedTables(workflowId, workflowState, tx)
|
|
})
|
|
|
|
logger.info(`[${requestId}] Successfully created workflow ${workflowId} with default blocks`)
|
|
|
|
captureServerEvent(
|
|
userId,
|
|
'workflow_created',
|
|
{ workflow_id: workflowId, workspace_id: workspaceId ?? '', name },
|
|
{
|
|
groups: workspaceId ? { workspace: workspaceId } : undefined,
|
|
setOnce: { first_workflow_created_at: new Date().toISOString() },
|
|
}
|
|
)
|
|
|
|
recordAudit({
|
|
workspaceId,
|
|
actorId: userId,
|
|
actorName: auth.userName,
|
|
actorEmail: auth.userEmail,
|
|
action: AuditAction.WORKFLOW_CREATED,
|
|
resourceType: AuditResourceType.WORKFLOW,
|
|
resourceId: workflowId,
|
|
resourceName: name,
|
|
description: `Created workflow "${name}"`,
|
|
metadata: { name },
|
|
request: req,
|
|
})
|
|
|
|
return NextResponse.json({
|
|
id: workflowId,
|
|
name,
|
|
description,
|
|
color,
|
|
workspaceId,
|
|
folderId,
|
|
sortOrder,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
startBlockId,
|
|
subBlockValues,
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.warn(`[${requestId}] Invalid workflow creation data`, {
|
|
errors: error.errors,
|
|
})
|
|
return NextResponse.json(
|
|
{ error: 'Invalid request data', details: error.errors },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
logger.error(`[${requestId}] Error creating workflow`, error)
|
|
return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 })
|
|
}
|
|
}
|