chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/export
|
||||
*
|
||||
* Export an entire workspace as a ZIP file or JSON (raw, unsanitized for admin backup/restore).
|
||||
*
|
||||
* Query Parameters:
|
||||
* - format: 'zip' (default) or 'json'
|
||||
*
|
||||
* Response:
|
||||
* - ZIP file download (Content-Type: application/zip)
|
||||
* - JSON: WorkspaceExportPayload
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { encodeFilenameForHeader } from '@/app/api/files/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type FolderExportPayload,
|
||||
parseWorkflowVariables,
|
||||
type WorkflowExportState,
|
||||
type WorkspaceExportPayload,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceExportAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ExportWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { format } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const workflows = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
|
||||
const folders = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId))
|
||||
|
||||
const workflowExports: Array<{
|
||||
workflow: WorkspaceExportPayload['workflows'][number]['workflow']
|
||||
state: WorkflowExportState
|
||||
}> = []
|
||||
|
||||
for (const wf of workflows) {
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(wf.id)
|
||||
|
||||
if (!normalizedData) {
|
||||
logger.warn(`Skipping workflow ${wf.id} - no normalized data found`)
|
||||
continue
|
||||
}
|
||||
|
||||
const variables = parseWorkflowVariables(wf.variables)
|
||||
|
||||
const state: WorkflowExportState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
metadata: {
|
||||
name: wf.name,
|
||||
description: wf.description ?? undefined,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables,
|
||||
}
|
||||
|
||||
workflowExports.push({
|
||||
workflow: {
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
description: wf.description,
|
||||
workspaceId: wf.workspaceId,
|
||||
folderId: wf.folderId,
|
||||
},
|
||||
state,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load workflow ${wf.id}:`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
const folderExports: FolderExportPayload[] = folders.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parentId: f.parentId,
|
||||
}))
|
||||
|
||||
logger.info(
|
||||
`Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders`
|
||||
)
|
||||
|
||||
const auditExport = () =>
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.WORKSPACE_EXPORTED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
resourceName: workspaceData.name,
|
||||
description: `Admin API exported workspace "${workspaceData.name}"`,
|
||||
metadata: {
|
||||
format,
|
||||
workflowCount: workflowExports.length,
|
||||
folderCount: folderExports.length,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
if (format === 'json') {
|
||||
auditExport()
|
||||
const exportPayload: WorkspaceExportPayload = {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
workspace: {
|
||||
id: workspaceData.id,
|
||||
name: workspaceData.name,
|
||||
},
|
||||
workflows: workflowExports,
|
||||
folders: folderExports,
|
||||
}
|
||||
|
||||
return singleResponse(exportPayload)
|
||||
}
|
||||
|
||||
const zipWorkflows = workflowExports.map((wf) => ({
|
||||
workflow: {
|
||||
id: wf.workflow.id,
|
||||
name: wf.workflow.name,
|
||||
description: wf.workflow.description ?? undefined,
|
||||
folderId: wf.workflow.folderId,
|
||||
},
|
||||
state: wf.state,
|
||||
variables: wf.state.variables,
|
||||
}))
|
||||
|
||||
const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports)
|
||||
const arrayBuffer = await zipBlob.arrayBuffer()
|
||||
|
||||
const sanitizedName = sanitizePathSegment(workspaceData.name)
|
||||
const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip`
|
||||
|
||||
auditExport()
|
||||
return new NextResponse(arrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; ${encodeFilenameForHeader(filename)}`,
|
||||
'Content-Length': arrayBuffer.byteLength.toString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to export workspace', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to export workspace')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/folders
|
||||
*
|
||||
* List all folders in a workspace with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminFolder>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
|
||||
import { type AdminFolder, createPaginationMeta, toAdminFolder } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceFoldersAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspaceFoldersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [countResult, folders] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId)),
|
||||
db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId))
|
||||
.orderBy(workflowFolder.sortOrder, workflowFolder.name)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminFolder[] = folders.map(toAdminFolder)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(
|
||||
`Admin API: Listed ${data.length} folders in workspace ${workspaceId} (total: ${total})`
|
||||
)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspace folders', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to list folders')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* POST /api/v1/admin/workspaces/[id]/import
|
||||
*
|
||||
* Import workflows into a workspace from a ZIP file or JSON.
|
||||
*
|
||||
* Content-Type:
|
||||
* - application/zip or multipart/form-data (with 'file' field) for ZIP upload
|
||||
* - application/json for JSON payload
|
||||
*
|
||||
* JSON Body:
|
||||
* {
|
||||
* workflows: Array<{
|
||||
* content: string | object, // Workflow JSON
|
||||
* name?: string, // Override name
|
||||
* folderPath?: string[] // Folder path to create
|
||||
* }>
|
||||
* }
|
||||
*
|
||||
* Query Parameters:
|
||||
* - createFolders: 'true' (default) or 'false'
|
||||
* - rootFolderName: optional name for root import folder
|
||||
*
|
||||
* Response: WorkspaceImportResponse
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
adminV1ImportWorkspaceContract,
|
||||
adminV1WorkspaceImportBodySchema,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseJsonBody, parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type {
|
||||
ImportResult,
|
||||
WorkflowVariable,
|
||||
WorkspaceImportRequest,
|
||||
WorkspaceImportResponse,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceImportAPI')
|
||||
|
||||
/**
|
||||
* Body cap for admin bulk workflow imports, which can carry many serialized
|
||||
* workflows and legitimately exceed the default contract-route limit.
|
||||
*/
|
||||
const ADMIN_IMPORT_MAX_BODY_BYTES = 100 * 1024 * 1024
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ParsedWorkflow {
|
||||
content: string
|
||||
name: string
|
||||
folderPath: string[]
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ImportWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { createFolders, rootFolderName } = parsed.data.query
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceWithOwner(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
let workflowsToImport: ParsedWorkflow[] = []
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const rawBody = await parseJsonBody(request, 'response', ADMIN_IMPORT_MAX_BODY_BYTES)
|
||||
|
||||
if (!rawBody.success) {
|
||||
// Preserve the 413 for an oversized body; only invalid JSON maps to 400.
|
||||
return rawBody.reason === 'too_large'
|
||||
? rawBody.response
|
||||
: badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
|
||||
}
|
||||
|
||||
const validation = adminV1WorkspaceImportBodySchema.safeParse(rawBody.data)
|
||||
if (!validation.success) {
|
||||
return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }')
|
||||
}
|
||||
|
||||
const body = validation.data as WorkspaceImportRequest
|
||||
workflowsToImport = body.workflows.map((w) => ({
|
||||
content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content),
|
||||
name: w.name || 'Imported Workflow',
|
||||
folderPath: w.folderPath || [],
|
||||
}))
|
||||
} else if (
|
||||
contentType.includes('application/zip') ||
|
||||
contentType.includes('multipart/form-data')
|
||||
) {
|
||||
let zipBuffer: ArrayBuffer
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return badRequestResponse('No file provided in form data. Use field name "file".')
|
||||
}
|
||||
|
||||
zipBuffer = await file.arrayBuffer()
|
||||
} else {
|
||||
zipBuffer = await request.arrayBuffer()
|
||||
}
|
||||
|
||||
const blob = new Blob([zipBuffer], { type: 'application/zip' })
|
||||
const file = new File([blob], 'import.zip', { type: 'application/zip' })
|
||||
|
||||
const { workflows } = await extractWorkflowsFromZip(file)
|
||||
workflowsToImport = workflows
|
||||
} else {
|
||||
return badRequestResponse(
|
||||
'Unsupported Content-Type. Use application/json or application/zip.'
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowsToImport.length === 0) {
|
||||
return badRequestResponse('No workflows found to import')
|
||||
}
|
||||
|
||||
let rootFolderId: string | undefined
|
||||
if (rootFolderName && createFolders) {
|
||||
rootFolderId = generateId()
|
||||
await db.insert(workflowFolder).values({
|
||||
id: rootFolderId,
|
||||
name: rootFolderName,
|
||||
userId: workspaceData.ownerId,
|
||||
workspaceId,
|
||||
parentId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
const results: ImportResult[] = []
|
||||
|
||||
for (const wf of workflowsToImport) {
|
||||
const result = await importSingleWorkflow(
|
||||
wf,
|
||||
workspaceId,
|
||||
workspaceData.ownerId,
|
||||
createFolders,
|
||||
rootFolderId,
|
||||
folderMap
|
||||
)
|
||||
results.push(result)
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Admin API: Imported workflow ${result.workflowId} (${result.name})`)
|
||||
} else {
|
||||
logger.warn(`Admin API: Failed to import workflow ${result.name}: ${result.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const imported = results.filter((r) => r.success).length
|
||||
const failed = results.filter((r) => !r.success).length
|
||||
|
||||
logger.info(`Admin API: Import complete - ${imported} succeeded, ${failed} failed`)
|
||||
|
||||
const response: WorkspaceImportResponse = { imported, failed, results }
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to import into workspace', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to import workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
async function importSingleWorkflow(
|
||||
wf: ParsedWorkflow,
|
||||
workspaceId: string,
|
||||
ownerId: string,
|
||||
createFolders: boolean,
|
||||
rootFolderId: string | undefined,
|
||||
folderMap: Map<string, string>
|
||||
): Promise<ImportResult> {
|
||||
try {
|
||||
const { data: workflowData, errors } = parseWorkflowJson(wf.content)
|
||||
|
||||
if (!workflowData || errors.length > 0) {
|
||||
return {
|
||||
workflowId: '',
|
||||
name: wf.name,
|
||||
success: false,
|
||||
error: `Parse error: ${errors.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(wf.content, wf.name)
|
||||
let targetFolderId: string | null = rootFolderId || null
|
||||
|
||||
if (createFolders && wf.folderPath.length > 0) {
|
||||
let parentId = rootFolderId || null
|
||||
|
||||
for (let i = 0; i < wf.folderPath.length; i++) {
|
||||
const pathSegment = wf.folderPath.slice(0, i + 1).join('/')
|
||||
const fullPath = rootFolderId ? `root/${pathSegment}` : pathSegment
|
||||
|
||||
if (!folderMap.has(fullPath)) {
|
||||
const folderId = generateId()
|
||||
await db.insert(workflowFolder).values({
|
||||
id: folderId,
|
||||
name: wf.folderPath[i],
|
||||
userId: ownerId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
folderMap.set(fullPath, folderId)
|
||||
parentId = folderId
|
||||
} else {
|
||||
parentId = folderMap.get(fullPath)!
|
||||
}
|
||||
}
|
||||
|
||||
const fullFolderPath = rootFolderId
|
||||
? `root/${wf.folderPath.join('/')}`
|
||||
: wf.folderPath.join('/')
|
||||
targetFolderId = folderMap.get(fullFolderPath) || parentId
|
||||
}
|
||||
|
||||
const workflowId = generateId()
|
||||
const now = new Date()
|
||||
const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId)
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: ownerId,
|
||||
workspaceId,
|
||||
folderId: targetFolderId,
|
||||
name: dedupedName,
|
||||
description: workflowData.metadata?.description || 'Imported via Admin API',
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
|
||||
|
||||
if (!saveResult.success) {
|
||||
await db.delete(workflow).where(eq(workflow.id, workflowId))
|
||||
return {
|
||||
workflowId: '',
|
||||
name: dedupedName,
|
||||
success: false,
|
||||
error: `Failed to save state: ${saveResult.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowData.variables && Array.isArray(workflowData.variables)) {
|
||||
const variablesRecord: Record<string, WorkflowVariable> = {}
|
||||
workflowData.variables.forEach((v) => {
|
||||
const varId = v.id || generateId()
|
||||
variablesRecord[varId] = {
|
||||
id: varId,
|
||||
name: v.name,
|
||||
type: v.type || 'string',
|
||||
value: v.value,
|
||||
}
|
||||
})
|
||||
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({ variables: variablesRecord, updatedAt: new Date() })
|
||||
.where(eq(workflow.id, workflowId))
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
name: dedupedName,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowId: '',
|
||||
name: wf.name,
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error'),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/members/[memberId]
|
||||
*
|
||||
* Get workspace member details.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceMember>
|
||||
*
|
||||
* PATCH /api/v1/admin/workspaces/[id]/members/[memberId]
|
||||
*
|
||||
* Update member permissions.
|
||||
*
|
||||
* Body:
|
||||
* - permissions: 'admin' | 'write' | 'read' - New permission level
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceMember>
|
||||
*
|
||||
* DELETE /api/v1/admin/workspaces/[id]/members/[memberId]
|
||||
*
|
||||
* Remove member from workspace.
|
||||
*
|
||||
* Response: AdminSingleResponse<{ removed: true, memberId: string, userId: string }>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, user, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1GetWorkspaceMemberContract,
|
||||
adminV1RemoveWorkspaceMemberContract,
|
||||
adminV1UpdateWorkspaceMemberContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
||||
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
|
||||
WorkspaceBillingAccountRemovalError,
|
||||
} from '@/lib/workspaces/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import type { AdminWorkspaceMember } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceMemberDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
memberId: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId, memberId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [memberData] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
updatedAt: permissions.updatedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.id, memberId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!memberData) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
const data: AdminWorkspaceMember = {
|
||||
id: memberData.id,
|
||||
workspaceId,
|
||||
userId: memberData.userId,
|
||||
permissions: memberData.permissionType,
|
||||
createdAt: memberData.createdAt.toISOString(),
|
||||
updatedAt: memberData.updatedAt.toISOString(),
|
||||
userName: memberData.userName,
|
||||
userEmail: memberData.userEmail,
|
||||
userImage: memberData.userImage,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId })
|
||||
return internalErrorResponse('Failed to get workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const PATCH = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1UpdateWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId, memberId } = parsed.data.params
|
||||
const { permissions: permissionLevel } = parsed.data.body
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.id, memberId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (
|
||||
workspaceBilling?.billedAccountUserId === existingMember.userId &&
|
||||
permissionLevel !== 'admin'
|
||||
) {
|
||||
return badRequestResponse('Workspace billing account must retain admin permissions')
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(permissions)
|
||||
.set({ permissionType: permissionLevel, updatedAt: now })
|
||||
.where(eq(permissions.id, memberId))
|
||||
|
||||
const [userData] = await db
|
||||
.select({ name: user.name, email: user.email, image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, existingMember.userId))
|
||||
.limit(1)
|
||||
|
||||
const data: AdminWorkspaceMember = {
|
||||
id: existingMember.id,
|
||||
workspaceId,
|
||||
userId: existingMember.userId,
|
||||
permissions: permissionLevel,
|
||||
createdAt: existingMember.createdAt.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
userName: userData?.name ?? '',
|
||||
userEmail: userData?.email ?? '',
|
||||
userImage: userData?.image ?? null,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Updated member ${memberId} permissions to ${permissionLevel}`, {
|
||||
workspaceId,
|
||||
previousPermissions: existingMember.permissionType,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
|
||||
metadata: {
|
||||
memberId,
|
||||
targetUserId: existingMember.userId,
|
||||
previousPermissions: existingMember.permissionType,
|
||||
permissions: permissionLevel,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId })
|
||||
return internalErrorResponse('Failed to update workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1RemoveWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId, memberId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [existingMember] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.id, memberId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingMember) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceBilling?.billedAccountUserId === existingMember.userId) {
|
||||
return badRequestResponse(
|
||||
'Cannot remove the workspace billing account. Please reassign billing first.'
|
||||
)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
|
||||
tx,
|
||||
workspaceId,
|
||||
departingUserId: existingMember.userId,
|
||||
})
|
||||
|
||||
const workflowOwnershipReassignment =
|
||||
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx,
|
||||
workspaceIds: [workspaceId],
|
||||
departingUserId: existingMember.userId,
|
||||
})
|
||||
if (workflowOwnershipReassignment.unresolved.length > 0) {
|
||||
throw new WorkspaceBillingAccountRemovalError()
|
||||
}
|
||||
|
||||
await tx.delete(permissions).where(eq(permissions.id, memberId))
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, existingMember.userId)
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, {
|
||||
userId: existingMember.userId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: 'Admin API removed member from workspace',
|
||||
metadata: { memberId, targetUserId: existingMember.userId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
removed: true,
|
||||
memberId,
|
||||
userId: existingMember.userId,
|
||||
workspaceId,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceBillingAccountRemovalError) {
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId })
|
||||
return internalErrorResponse('Failed to remove workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/members
|
||||
*
|
||||
* List all members of a workspace with their permission details.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkspaceMember>
|
||||
*
|
||||
* POST /api/v1/admin/workspaces/[id]/members
|
||||
*
|
||||
* Add a user to a workspace with a specific permission level.
|
||||
* If the user already has permissions, updates their permission level.
|
||||
*
|
||||
* Body:
|
||||
* - userId: string - User ID to add
|
||||
* - permissions: 'admin' | 'write' | 'read' - Permission level
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceMember & { action: 'created' | 'updated' }>
|
||||
*
|
||||
* DELETE /api/v1/admin/workspaces/[id]/members
|
||||
*
|
||||
* Remove a user from a workspace.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - userId: string - User ID to remove
|
||||
*
|
||||
* Response: AdminSingleResponse<{ removed: true }>
|
||||
*/
|
||||
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import {
|
||||
adminV1CreateWorkspaceMemberContract,
|
||||
adminV1DeleteWorkspaceMemberContract,
|
||||
adminV1ListWorkspaceMembersContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import {
|
||||
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
|
||||
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
|
||||
WorkspaceBillingAccountRemovalError,
|
||||
} from '@/lib/workspaces/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
listResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkspaceMember, createPaginationMeta } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceMembersAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspaceMembersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [countResult, membersData] = await Promise.all([
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
|
||||
),
|
||||
db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
userId: permissions.userId,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
updatedAt: permissions.updatedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(permissions)
|
||||
.innerJoin(user, eq(permissions.userId, user.id))
|
||||
.where(
|
||||
and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))
|
||||
)
|
||||
.orderBy(permissions.createdAt)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].count
|
||||
const data: AdminWorkspaceMember[] = membersData.map((m) => ({
|
||||
id: m.id,
|
||||
workspaceId,
|
||||
userId: m.userId,
|
||||
permissions: m.permissionType,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
updatedAt: m.updatedAt.toISOString(),
|
||||
userName: m.userName,
|
||||
userEmail: m.userEmail,
|
||||
userImage: m.userImage,
|
||||
}))
|
||||
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspace members', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to list workspace members')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1CreateWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { userId, permissions: permissionLevel } = parsed.data.body
|
||||
|
||||
try {
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceBilling?.billedAccountUserId === userId && permissionLevel !== 'admin') {
|
||||
return badRequestResponse('Workspace billing account must retain admin permissions')
|
||||
}
|
||||
|
||||
const [userData] = await db
|
||||
.select({ id: user.id, name: user.name, email: user.email, image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userData) {
|
||||
return notFoundResponse('User')
|
||||
}
|
||||
|
||||
const [existingPermission] = await db
|
||||
.select({
|
||||
id: permissions.id,
|
||||
permissionType: permissions.permissionType,
|
||||
createdAt: permissions.createdAt,
|
||||
updatedAt: permissions.updatedAt,
|
||||
})
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingPermission) {
|
||||
if (existingPermission.permissionType !== permissionLevel) {
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(permissions)
|
||||
.set({ permissionType: permissionLevel, updatedAt: now })
|
||||
.where(eq(permissions.id, existingPermission.id))
|
||||
|
||||
logger.info(`Admin API: Updated user ${userId} permissions in workspace ${workspaceId}`, {
|
||||
previousPermissions: existingPermission.permissionType,
|
||||
newPermissions: permissionLevel,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_ROLE_CHANGED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Admin API changed workspace member permissions to ${permissionLevel}`,
|
||||
metadata: {
|
||||
targetUserId: userId,
|
||||
previousPermissions: existingPermission.permissionType,
|
||||
permissions: permissionLevel,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({
|
||||
id: existingPermission.id,
|
||||
workspaceId,
|
||||
userId,
|
||||
permissions: permissionLevel,
|
||||
createdAt: existingPermission.createdAt.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
userImage: userData.image,
|
||||
action: 'updated' as const,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
id: existingPermission.id,
|
||||
workspaceId,
|
||||
userId,
|
||||
permissions: existingPermission.permissionType,
|
||||
createdAt: existingPermission.createdAt.toISOString(),
|
||||
updatedAt: existingPermission.updatedAt.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
userImage: userData.image,
|
||||
action: 'already_member' as const,
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const permissionId = generateId()
|
||||
|
||||
await db.insert(permissions).values({
|
||||
id: permissionId,
|
||||
userId,
|
||||
entityType: 'workspace',
|
||||
entityId: workspaceId,
|
||||
permissionType: permissionLevel,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Added user ${userId} to workspace ${workspaceId}`, {
|
||||
permissions: permissionLevel,
|
||||
permissionId,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_ADDED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: `Admin API added member to workspace with ${permissionLevel} permissions`,
|
||||
metadata: { targetUserId: userId, permissions: permissionLevel },
|
||||
request,
|
||||
})
|
||||
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: userId,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
id: permissionId,
|
||||
workspaceId,
|
||||
userId,
|
||||
permissions: permissionLevel,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
userName: userData.name,
|
||||
userEmail: userData.email,
|
||||
userImage: userData.image,
|
||||
action: 'created' as const,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to add workspace member', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to add workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1DeleteWorkspaceMemberContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { userId } = parsed.data.query
|
||||
let targetUserId: string | undefined
|
||||
|
||||
try {
|
||||
targetUserId = userId
|
||||
|
||||
const workspaceData = await getWorkspaceById(workspaceId)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [workspaceBilling] = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (workspaceBilling?.billedAccountUserId === userId) {
|
||||
return badRequestResponse(
|
||||
'Cannot remove the workspace billing account. Please reassign billing first.'
|
||||
)
|
||||
}
|
||||
|
||||
const [existingPermission] = await db
|
||||
.select({ id: permissions.id })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existingPermission) {
|
||||
return notFoundResponse('Workspace member')
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({
|
||||
tx,
|
||||
workspaceId,
|
||||
departingUserId: userId,
|
||||
})
|
||||
|
||||
const workflowOwnershipReassignment =
|
||||
await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({
|
||||
tx,
|
||||
workspaceIds: [workspaceId],
|
||||
departingUserId: userId,
|
||||
})
|
||||
if (workflowOwnershipReassignment.unresolved.length > 0) {
|
||||
throw new WorkspaceBillingAccountRemovalError()
|
||||
}
|
||||
|
||||
await tx.delete(permissions).where(eq(permissions.id, existingPermission.id))
|
||||
|
||||
await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId)
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: 'admin-api',
|
||||
action: AuditAction.MEMBER_REMOVED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
description: 'Admin API removed member from workspace',
|
||||
metadata: { targetUserId: userId },
|
||||
request,
|
||||
})
|
||||
|
||||
return singleResponse({ removed: true, userId, workspaceId })
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceBillingAccountRemovalError) {
|
||||
return badRequestResponse(error.message)
|
||||
}
|
||||
logger.error('Admin API: Failed to remove workspace member', {
|
||||
error,
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
})
|
||||
return internalErrorResponse('Failed to remove workspace member')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]
|
||||
*
|
||||
* Get workspace details including workflow and folder counts.
|
||||
*
|
||||
* Response: AdminSingleResponse<AdminWorkspaceDetail>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { adminV1GetWorkspaceContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
internalErrorResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkspaceDetail, toAdminWorkspace } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceDetailAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1GetWorkspaceContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [workflowCountResult, folderCountResult] = await Promise.all([
|
||||
db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId)),
|
||||
])
|
||||
|
||||
const data: AdminWorkspaceDetail = {
|
||||
...toAdminWorkspace(workspaceData),
|
||||
workflowCount: workflowCountResult[0].count,
|
||||
folderCount: folderCountResult[0].count,
|
||||
}
|
||||
|
||||
logger.info(`Admin API: Retrieved workspace ${workspaceId}`)
|
||||
|
||||
return singleResponse(data)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to get workspace', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to get workspace')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces/[id]/workflows
|
||||
*
|
||||
* List all workflows in a workspace with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkflow>
|
||||
*
|
||||
* DELETE /api/v1/admin/workspaces/[id]/workflows
|
||||
*
|
||||
* Delete all workflows in a workspace (clean slate for reimport).
|
||||
*
|
||||
* Response: { success: true, deleted: number }
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
adminV1DeleteWorkspaceWorkflowsContract,
|
||||
adminV1ListWorkspaceWorkflowsContract,
|
||||
} from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses'
|
||||
import { type AdminWorkflow, createPaginationMeta, toAdminWorkflow } from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspaceWorkflowsAPI')
|
||||
|
||||
interface RouteParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspaceWorkflowsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const [countResult, workflows] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
|
||||
db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
workspaceId: workflow.workspaceId,
|
||||
folderId: workflow.folderId,
|
||||
isDeployed: workflow.isDeployed,
|
||||
deployedAt: workflow.deployedAt,
|
||||
runCount: workflow.runCount,
|
||||
lastRunAt: workflow.lastRunAt,
|
||||
createdAt: workflow.createdAt,
|
||||
updatedAt: workflow.updatedAt,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
.orderBy(workflow.name)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminWorkflow[] = workflows.map(toAdminWorkflow)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(
|
||||
`Admin API: Listed ${data.length} workflows in workspace ${workspaceId} (total: ${total})`
|
||||
)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspace workflows', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to list workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||
const parsed = await parseRequest(adminV1DeleteWorkspaceWorkflowsContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: workspaceId } = parsed.data.params
|
||||
|
||||
try {
|
||||
const [workspaceData] = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceData) {
|
||||
return notFoundResponse('Workspace')
|
||||
}
|
||||
|
||||
const workflowsToDelete = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt)))
|
||||
|
||||
if (workflowsToDelete.length === 0) {
|
||||
return NextResponse.json({ success: true, deleted: 0 })
|
||||
}
|
||||
|
||||
const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, {
|
||||
requestId: `admin-workspace-${workspaceId}`,
|
||||
})
|
||||
|
||||
logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({ success: true, deleted: deletedCount })
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId })
|
||||
return internalErrorResponse('Failed to delete workflows')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/v1/admin/workspaces
|
||||
*
|
||||
* List all workspaces with pagination.
|
||||
*
|
||||
* Query Parameters:
|
||||
* - limit: number (default: 50, max: 250)
|
||||
* - offset: number (default: 0)
|
||||
*
|
||||
* Response: AdminListResponse<AdminWorkspace>
|
||||
*/
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count } from 'drizzle-orm'
|
||||
import { adminV1ListWorkspacesContract } from '@/lib/api/contracts/v1/admin'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses'
|
||||
import {
|
||||
type AdminWorkspace,
|
||||
createPaginationMeta,
|
||||
toAdminWorkspace,
|
||||
} from '@/app/api/v1/admin/types'
|
||||
|
||||
const logger = createLogger('AdminWorkspacesAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
withAdminAuth(async (request) => {
|
||||
const parsed = await parseRequest(adminV1ListWorkspacesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { limit, offset } = parsed.data.query
|
||||
|
||||
try {
|
||||
const [countResult, workspaces] = await Promise.all([
|
||||
db.select({ total: count() }).from(workspace),
|
||||
db.select().from(workspace).orderBy(workspace.name).limit(limit).offset(offset),
|
||||
])
|
||||
|
||||
const total = countResult[0].total
|
||||
const data: AdminWorkspace[] = workspaces.map(toAdminWorkspace)
|
||||
const pagination = createPaginationMeta(total, limit, offset)
|
||||
|
||||
logger.info(`Admin API: Listed ${data.length} workspaces (total: ${total})`)
|
||||
|
||||
return listResponse(data, pagination)
|
||||
} catch (error) {
|
||||
logger.error('Admin API: Failed to list workspaces', { error })
|
||||
return internalErrorResponse('Failed to list workspaces')
|
||||
}
|
||||
})
|
||||
)
|
||||
Reference in New Issue
Block a user