Compare commits

...

1 Commits

Author SHA1 Message Date
Siddharth Ganesan
198a9ca69a v1 2025-12-02 11:15:35 -08:00
8 changed files with 612 additions and 12 deletions

View File

@@ -0,0 +1,159 @@
import { db } from '@sim/db'
import { user, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { sanitizeForExport } from '@/lib/workflows/json-sanitizer'
const logger = createLogger('AdminImportWorkflowAPI')
const ImportWorkflowSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
targetWorkspaceId: z.string().min(1, 'Target workspace ID is required'),
deploymentVersion: z.number().int().positive().optional(),
})
/**
* POST /api/admin/import-workflow
* Export a workflow from database by ID (superuser only)
*/
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized import attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is superuser
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-superuser attempted workflow import: ${session.user.id}`)
return NextResponse.json({ error: 'Forbidden - Superuser access required' }, { status: 403 })
}
const body = await request.json()
const validation = ImportWorkflowSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{ error: 'Invalid request', details: validation.error.errors },
{ status: 400 }
)
}
const { workflowId, targetWorkspaceId, deploymentVersion } = validation.data
// Fetch workflow metadata
const [workflowData] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowData) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
let workflowState: any
let sourceLabel = 'current state'
if (deploymentVersion !== undefined) {
// Load from deployment version
const [deployedVersion] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, deploymentVersion)
)
)
.limit(1)
if (!deployedVersion?.state) {
return NextResponse.json({ error: `Deployment version ${deploymentVersion} not found` }, { status: 404 })
}
const deployedState = deployedVersion.state as any
workflowState = {
blocks: deployedState.blocks || {},
edges: Array.isArray(deployedState.edges) ? deployedState.edges : [],
loops: deployedState.loops || {},
parallels: deployedState.parallels || {},
metadata: {
name: workflowData.name,
description: workflowData.description ?? undefined,
color: workflowData.color ?? undefined,
},
variables: Array.isArray(deployedState.variables) ? deployedState.variables : [],
}
sourceLabel = `deployment v${deploymentVersion}`
} else {
// Load current state from normalized tables
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return NextResponse.json({ error: 'Workflow has no data' }, { status: 404 })
}
let workflowVariables: any[] = []
if (workflowData.variables && typeof workflowData.variables === 'object') {
workflowVariables = Object.values(workflowData.variables).map((v: any) => ({
id: v.id,
name: v.name,
type: v.type,
value: v.value,
}))
}
workflowState = {
blocks: normalizedData.blocks || {},
edges: Array.isArray(normalizedData.edges) ? normalizedData.edges : [],
loops: normalizedData.loops || {},
parallels: normalizedData.parallels || {},
metadata: {
name: workflowData.name,
description: workflowData.description ?? undefined,
color: workflowData.color ?? undefined,
},
variables: workflowVariables,
}
}
const exportState = sanitizeForExport(workflowState)
logger.info(`[${requestId}] Exported workflow ${workflowId} (${sourceLabel})`)
return NextResponse.json({
success: true,
workflow: exportState,
metadata: {
originalId: workflowId,
originalName: workflowData.name,
originalDescription: workflowData.description,
targetWorkspaceId,
deploymentVersion: deploymentVersion ?? null,
source: sourceLabel,
},
})
} catch (error) {
logger.error(`[${requestId}] Error importing workflow:`, error)
return NextResponse.json(
{ error: 'Failed to import workflow' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,65 @@
import { db } from '@sim/db'
import { user, workflowDeploymentVersion } from '@sim/db/schema'
import { desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
const logger = createLogger('AdminWorkflowDeploymentsAPI')
/**
* GET /api/admin/workflow-deployments?workflowId=xxx
* List all deployment versions for a workflow (superuser only)
*/
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is superuser
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (!currentUser[0]?.isSuperUser) {
return NextResponse.json({ error: 'Forbidden - Superuser access required' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const workflowId = searchParams.get('workflowId')
if (!workflowId) {
return NextResponse.json({ error: 'workflowId query parameter is required' }, { status: 400 })
}
const versions = await db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
createdBy: workflowDeploymentVersion.createdBy,
deployedBy: user.name,
})
.from(workflowDeploymentVersion)
.leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id))
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
.orderBy(desc(workflowDeploymentVersion.version))
logger.info(`[${requestId}] Retrieved ${versions.length} deployments for workflow ${workflowId}`)
return NextResponse.json({ success: true, versions })
} catch (error) {
logger.error(`[${requestId}] Error listing deployments:`, error)
return NextResponse.json({ error: 'Failed to list deployments' }, { status: 500 })
}
}

View File

@@ -12,3 +12,4 @@ export { SettingsNavigation } from './settings-navigation/settings-navigation'
export { SSO } from './sso/sso'
export { Subscription } from './subscription/subscription'
export { TeamManagement } from './team-management/team-management'
export { WorkflowImport } from './workflow-import/workflow-import'

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import {
Bot,
CreditCard,
Download,
FileCode,
Files,
Home,
@@ -27,6 +28,7 @@ import { generalSettingsKeys } from '@/hooks/queries/general-settings'
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
import { useSuperUserStatus } from '@/hooks/queries/super-user'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -48,6 +50,7 @@ interface SettingsNavigationProps {
| 'copilot'
| 'mcp'
| 'custom-tools'
| 'workflow-import'
) => void
hasOrganization: boolean
}
@@ -68,6 +71,7 @@ type NavigationItem = {
| 'privacy'
| 'mcp'
| 'custom-tools'
| 'workflow-import'
label: string
icon: React.ComponentType<{ className?: string }>
hideWhenBillingDisabled?: boolean
@@ -75,6 +79,7 @@ type NavigationItem = {
requiresEnterprise?: boolean
requiresOwner?: boolean
requiresHosted?: boolean
requiresSuperUser?: boolean
}
const allNavigationItems: NavigationItem[] = [
@@ -155,6 +160,12 @@ const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
requiresOwner: true,
},
{
id: 'workflow-import',
label: 'Workflow Import',
icon: Download,
requiresSuperUser: true,
},
]
export function SettingsNavigation({
@@ -176,6 +187,10 @@ export function SettingsNavigation({
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
const hasEnterprisePlan = subscriptionStatus.isEnterprise
// Check superuser status
const { data: superUserData } = useSuperUserStatus()
const isSuperUser = superUserData?.isSuperUser ?? false
// Use React Query to check SSO provider ownership (with proper caching)
// Only fetch if not hosted (hosted uses billing/org checks)
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
@@ -224,9 +239,13 @@ export function SettingsNavigation({
return false
}
if (item.requiresSuperUser && !isSuperUser) {
return false
}
return true
})
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner])
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner, isSuperUser])
// Prefetch functions for React Query
const prefetchGeneral = () => {

View File

@@ -0,0 +1,325 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Download, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { createLogger } from '@/lib/logs/console/logger'
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
import { useCreateWorkflow, workflowKeys } from '@/hooks/queries/workflows'
import { useQueryClient } from '@tanstack/react-query'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSuperUserStatus } from '@/hooks/queries/super-user'
const logger = createLogger('WorkflowImport')
/**
* WorkflowImport Settings Component
* Allows superusers to import workflows by ID from the database
*/
export function WorkflowImport() {
const params = useParams()
const router = useRouter()
const queryClient = useQueryClient()
const createWorkflowMutation = useCreateWorkflow()
const { data: superUserData, isLoading: loadingSuperUser } = useSuperUserStatus()
const workspaceId = params?.workspaceId as string
const isSuperUser = superUserData?.isSuperUser ?? false
const [workflowId, setWorkflowId] = useState('')
const [useDeployment, setUseDeployment] = useState(false)
const [deployments, setDeployments] = useState<any[]>([])
const [selectedDeployment, setSelectedDeployment] = useState<string>('')
const [loadingDeployments, setLoadingDeployments] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Fetch deployments when workflow ID changes and useDeployment is enabled
useEffect(() => {
const fetchDeployments = async () => {
if (!useDeployment || !workflowId.trim()) {
setDeployments([])
setSelectedDeployment('')
return
}
setLoadingDeployments(true)
setError(null)
try {
const response = await fetch(`/api/admin/workflow-deployments?workflowId=${workflowId.trim()}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch deployments')
}
const data = await response.json()
setDeployments(data.versions || [])
if (data.versions && data.versions.length > 0) {
setSelectedDeployment(String(data.versions[0].version))
} else {
setSelectedDeployment('')
}
} catch (err) {
logger.error('Failed to fetch deployments:', err)
setError(err instanceof Error ? err.message : 'Failed to fetch deployments')
setDeployments([])
setSelectedDeployment('')
} finally {
setLoadingDeployments(false)
}
}
fetchDeployments()
}, [useDeployment, workflowId])
/**
* Handle workflow import
*/
const handleImport = async () => {
if (!workflowId.trim()) {
setError('Please enter a workflow ID')
return
}
setIsImporting(true)
setError(null)
setSuccess(null)
try {
// Build request
const requestBody: any = {
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
}
if (useDeployment && selectedDeployment) {
requestBody.deploymentVersion = Number.parseInt(selectedDeployment, 10)
}
// Fetch workflow data from admin API
const response = await fetch('/api/admin/import-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to fetch workflow')
}
const { workflow: exportState, metadata } = await response.json()
// Parse the workflow JSON (regenerate IDs)
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(
JSON.stringify(exportState),
true
)
if (!workflowData || parseErrors.length > 0) {
throw new Error(`Failed to parse workflow: ${parseErrors.join(', ')}`)
}
// Clear diff state
useWorkflowDiffStore.getState().clearDiff()
// Create new workflow
const importSuffix = metadata.deploymentVersion
? ` (Imported v${metadata.deploymentVersion})`
: ' (Imported)'
const workflowColor = exportState.state?.metadata?.color || '#3972F6'
const result = await createWorkflowMutation.mutateAsync({
name: `${metadata.originalName}${importSuffix}`,
description: metadata.originalDescription || `Imported from ${metadata.source}`,
workspaceId,
color: workflowColor,
})
const newWorkflowId = result.id
// Save workflow state
const stateResponse = await fetch(`/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(workflowData),
})
if (!stateResponse.ok) {
// Clean up on failure
await fetch(`/api/workflows/${newWorkflowId}`, { method: 'DELETE' })
throw new Error('Failed to save workflow state')
}
// Save variables if present
if (workflowData.variables && Array.isArray(workflowData.variables) && workflowData.variables.length > 0) {
const variablesPayload = workflowData.variables.map((v: any) => ({
id: v.id,
workflowId: newWorkflowId,
name: v.name,
type: v.type,
value: v.value,
}))
await fetch(`/api/workflows/${newWorkflowId}/variables`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variables: variablesPayload }),
})
}
// Refresh workflow list
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
const successMsg = metadata.deploymentVersion
? `Imported "${metadata.originalName}" v${metadata.deploymentVersion}`
: `Imported "${metadata.originalName}"`
setSuccess(successMsg)
setWorkflowId('')
// Navigate to new workflow
setTimeout(() => {
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
}, 1000)
} catch (err) {
logger.error('Failed to import workflow:', err)
setError(err instanceof Error ? err.message : 'Import failed')
} finally {
setIsImporting(false)
}
}
if (loadingSuperUser) {
return (
<div className='flex h-full items-center justify-center'>
<Loader2 className='h-6 w-6 animate-spin text-[var(--text-40)]' />
</div>
)
}
if (!isSuperUser) {
return (
<div className='flex h-full items-center justify-center'>
<p className='text-[var(--text-40)]'>This feature is only available to superusers.</p>
</div>
)
}
return (
<div className='h-full overflow-y-auto p-6'>
<div className='mx-auto max-w-2xl space-y-6'>
<div>
<h2 className='text-lg font-semibold text-[var(--text-90)]'>Import Workflow</h2>
<p className='mt-1 text-sm text-[var(--text-40)]'>
Import a workflow from the database by ID into this workspace.
</p>
</div>
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='workflow-id'>Workflow ID</Label>
<Input
id='workflow-id'
placeholder='Enter workflow ID'
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
disabled={isImporting}
className='font-mono'
/>
</div>
<div className='flex items-center justify-between rounded-md border border-[var(--surface-11)] p-3'>
<div>
<Label htmlFor='use-deployment'>Load from deployment</Label>
<p className='text-xs text-[var(--text-40)]'>Import a deployed version</p>
</div>
<Switch
id='use-deployment'
checked={useDeployment}
onCheckedChange={setUseDeployment}
disabled={isImporting}
/>
</div>
{useDeployment && (
<div className='space-y-2'>
<Label>Deployment Version</Label>
{loadingDeployments ? (
<div className='flex items-center gap-2 rounded-md border border-[var(--surface-11)] p-3 text-sm text-[var(--text-40)]'>
<Loader2 className='h-4 w-4 animate-spin' />
Loading...
</div>
) : deployments.length === 0 ? (
<div className='rounded-md border border-[var(--surface-11)] p-3 text-sm text-[var(--text-40)]'>
{workflowId.trim() ? 'No deployments found' : 'Enter a workflow ID'}
</div>
) : (
<Select value={selectedDeployment} onValueChange={setSelectedDeployment} disabled={isImporting}>
<SelectTrigger>
<SelectValue placeholder='Select version' />
</SelectTrigger>
<SelectContent>
{deployments.map((d) => (
<SelectItem key={d.version} value={String(d.version)}>
v{d.version}{d.name ? ` - ${d.name}` : ''}{d.isActive ? ' (Active)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{error && (
<div className='rounded-md bg-red-500/10 p-3 text-sm text-red-500'>{error}</div>
)}
{success && (
<div className='rounded-md bg-green-500/10 p-3 text-sm text-green-500'>{success}</div>
)}
<Button
onClick={handleImport}
disabled={isImporting || !workflowId.trim() || (useDeployment && !selectedDeployment)}
className='w-full'
>
{isImporting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Importing...
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
Import Workflow
</>
)}
</Button>
</div>
<div className='rounded-md border border-yellow-500/20 bg-yellow-500/5 p-4'>
<p className='text-xs text-yellow-600 dark:text-yellow-500'>
Superuser Only - Use responsibly
</p>
</div>
</div>
</div>
)
}

View File

@@ -20,6 +20,7 @@ import {
SSO,
Subscription,
TeamManagement,
WorkflowImport,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components'
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile'
import { useGeneralSettings } from '@/hooks/queries/general-settings'
@@ -49,6 +50,7 @@ type SettingsSection =
| 'copilot'
| 'mcp'
| 'custom-tools'
| 'workflow-import'
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
@@ -212,6 +214,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<CustomTools />
</div>
)}
{activeSection === 'workflow-import' && (
<div className='h-full'>
<WorkflowImport />
</div>
)}
</div>
</div>
</ModalContent>

View File

@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query'
export const superUserKeys = {
status: () => ['superUserStatus'] as const,
}
/**
* Hook to fetch the current user's superuser status
*/
export function useSuperUserStatus() {
return useQuery({
queryKey: superUserKeys.status(),
queryFn: async () => {
const response = await fetch('/api/user/super-user')
if (!response.ok) {
throw new Error('Failed to fetch super user status')
}
return response.json() as Promise<{ isSuperUser: boolean }>
},
staleTime: 5 * 60 * 1000,
retry: false,
})
}

View File

@@ -326,19 +326,19 @@ export async function saveWorkflowToNormalizedTables(
const canonicalLoops = generateLoopBlocks(blockRecords)
const canonicalParallels = generateParallelBlocks(blockRecords)
// Load existing webhooks BEFORE transaction to avoid aborting transaction on failure
let existingWebhooks: any[] = []
try {
existingWebhooks = await db.select().from(webhook).where(eq(webhook.workflowId, workflowId))
} catch (webhookError) {
// Webhook table might not exist or query might fail - skip preservation
logger.debug('Could not load webhooks before save, skipping preservation', {
error: webhookError instanceof Error ? webhookError.message : String(webhookError),
})
}
// Start a transaction
await db.transaction(async (tx) => {
// Snapshot existing webhooks before deletion to preserve them through the cycle
let existingWebhooks: any[] = []
try {
existingWebhooks = await tx.select().from(webhook).where(eq(webhook.workflowId, workflowId))
} catch (webhookError) {
// Webhook table might not be available in test environments
logger.debug('Could not load webhooks before save, skipping preservation', {
error: webhookError instanceof Error ? webhookError.message : String(webhookError),
})
}
// Clear existing data for this workflow
await Promise.all([
tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)),