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,390 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { duplicateFolderContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FolderDuplicateAPI')
|
||||
|
||||
// POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows
|
||||
export const POST = withRouteHandler(
|
||||
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const { id: sourceFolderId } = await context.params
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized folder duplication attempt for ${sourceFolderId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(duplicateFolderContract, req, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, workspaceId, parentId, color, newId: clientNewId } = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`)
|
||||
|
||||
const sourceFolder = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.id, sourceFolderId), isNull(workflowFolder.archivedAt)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!sourceFolder) {
|
||||
throw new Error('Source folder not found')
|
||||
}
|
||||
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
sourceFolder.workspaceId
|
||||
)
|
||||
|
||||
if (!userPermission || userPermission === 'read') {
|
||||
throw new Error('Source folder not found or access denied')
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || sourceFolder.workspaceId
|
||||
if (targetWorkspaceId !== sourceFolder.workspaceId) {
|
||||
throw new Error('Cross-workspace folder duplication is not supported')
|
||||
}
|
||||
|
||||
const { newFolderId, folderMapping, workflowStats } = await db.transaction(async (tx) => {
|
||||
const newFolderId = clientNewId || generateId()
|
||||
const now = new Date()
|
||||
const targetParentId = parentId ?? sourceFolder.parentId
|
||||
await assertTargetParentFolderMutable(tx, targetParentId, targetWorkspaceId, sourceFolderId)
|
||||
|
||||
const folderParentCondition = targetParentId
|
||||
? eq(workflowFolder.parentId, targetParentId)
|
||||
: isNull(workflowFolder.parentId)
|
||||
const workflowParentCondition = targetParentId
|
||||
? eq(workflow.folderId, targetParentId)
|
||||
: isNull(workflow.folderId)
|
||||
|
||||
const [[folderResult], [workflowResult]] = await Promise.all([
|
||||
tx
|
||||
.select({ minSortOrder: min(workflowFolder.sortOrder) })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
|
||||
tx
|
||||
.select({ minSortOrder: min(workflow.sortOrder) })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
|
||||
])
|
||||
|
||||
const minSortOrder = [folderResult?.minSortOrder, workflowResult?.minSortOrder].reduce<
|
||||
number | null
|
||||
>((currentMin, candidate) => {
|
||||
if (candidate == null) return currentMin
|
||||
if (currentMin == null) return candidate
|
||||
return Math.min(currentMin, candidate)
|
||||
}, null)
|
||||
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||
const deduplicatedName = await deduplicateFolderName(
|
||||
tx,
|
||||
targetWorkspaceId,
|
||||
targetParentId,
|
||||
name
|
||||
)
|
||||
|
||||
await tx.insert(workflowFolder).values({
|
||||
id: newFolderId,
|
||||
userId: session.user.id,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name: deduplicatedName,
|
||||
color: color || sourceFolder.color,
|
||||
parentId: targetParentId,
|
||||
sortOrder,
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const folderMapping = new Map<string, string>([[sourceFolderId, newFolderId]])
|
||||
await duplicateFolderStructure(
|
||||
tx,
|
||||
sourceFolderId,
|
||||
newFolderId,
|
||||
sourceFolder.workspaceId,
|
||||
targetWorkspaceId,
|
||||
session.user.id,
|
||||
now,
|
||||
folderMapping
|
||||
)
|
||||
|
||||
const workflowStats = await duplicateWorkflowsInFolderTree(
|
||||
tx,
|
||||
sourceFolder.workspaceId,
|
||||
targetWorkspaceId,
|
||||
folderMapping,
|
||||
session.user.id,
|
||||
requestId
|
||||
)
|
||||
|
||||
return { newFolderId, folderMapping, workflowStats }
|
||||
})
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(
|
||||
`[${requestId}] Successfully duplicated folder ${sourceFolderId} to ${newFolderId} in ${elapsed}ms`,
|
||||
{
|
||||
foldersCount: folderMapping.size,
|
||||
workflowsCount: workflowStats.total,
|
||||
workflowsSucceeded: workflowStats.succeeded,
|
||||
}
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId: targetWorkspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.FOLDER_DUPLICATED,
|
||||
resourceType: AuditResourceType.FOLDER,
|
||||
resourceId: newFolderId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: name,
|
||||
description: `Duplicated folder "${sourceFolder.name}" as "${name}"`,
|
||||
metadata: {
|
||||
sourceId: sourceFolder.id,
|
||||
affected: { workflows: workflowStats.succeeded, folders: folderMapping.size },
|
||||
},
|
||||
request: req,
|
||||
})
|
||||
|
||||
const duplicatedFolder = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, newFolderId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
return NextResponse.json({ folder: duplicatedFolder }, { status: 201 })
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
if (error.message === 'Source folder not found') {
|
||||
logger.warn(`[${requestId}] Source folder ${sourceFolderId} not found`)
|
||||
return NextResponse.json({ error: 'Source folder not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (error.message === 'Source folder not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (error.message === 'Cross-workspace folder duplication is not supported') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted cross-workspace folder duplication for ${sourceFolderId}`
|
||||
)
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
if (
|
||||
error.message === 'Target parent folder not found' ||
|
||||
error.message === 'Cannot duplicate folder into itself or one of its descendants'
|
||||
) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(
|
||||
`[${requestId}] Error duplicating folder ${sourceFolderId} after ${elapsed}ms:`,
|
||||
error
|
||||
)
|
||||
return NextResponse.json({ error: 'Failed to duplicate folder' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function assertTargetParentFolderMutable(
|
||||
tx: DbOrTx,
|
||||
parentId: string | null,
|
||||
targetWorkspaceId: string,
|
||||
sourceFolderId: string
|
||||
): Promise<void> {
|
||||
let currentFolderId = parentId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentFolderId && !visited.has(currentFolderId)) {
|
||||
visited.add(currentFolderId)
|
||||
const [folder] = await tx
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
parentId: workflowFolder.parentId,
|
||||
workspaceId: workflowFolder.workspaceId,
|
||||
locked: workflowFolder.locked,
|
||||
archivedAt: workflowFolder.archivedAt,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, currentFolderId))
|
||||
.limit(1)
|
||||
|
||||
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
|
||||
throw new Error('Target parent folder not found')
|
||||
}
|
||||
if (folder.id === sourceFolderId) {
|
||||
throw new Error('Cannot duplicate folder into itself or one of its descendants')
|
||||
}
|
||||
if (folder.locked) {
|
||||
throw new FolderLockedError()
|
||||
}
|
||||
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
}
|
||||
|
||||
async function deduplicateFolderName(
|
||||
tx: DbOrTx,
|
||||
workspaceId: string,
|
||||
parentId: string | null,
|
||||
requestedName: string
|
||||
): Promise<string> {
|
||||
const parentCondition = parentId
|
||||
? eq(workflowFolder.parentId, parentId)
|
||||
: isNull(workflowFolder.parentId)
|
||||
const siblingRows = await tx
|
||||
.select({ name: workflowFolder.name })
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.workspaceId, workspaceId),
|
||||
parentCondition,
|
||||
isNull(workflowFolder.archivedAt)
|
||||
)
|
||||
)
|
||||
const siblingNames = new Set(siblingRows.map((row) => row.name))
|
||||
if (!siblingNames.has(requestedName)) return requestedName
|
||||
|
||||
let suffix = 1
|
||||
let candidate = `${requestedName} (${suffix})`
|
||||
while (siblingNames.has(candidate)) {
|
||||
suffix += 1
|
||||
candidate = `${requestedName} (${suffix})`
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
async function duplicateFolderStructure(
|
||||
tx: DbOrTx,
|
||||
sourceFolderId: string,
|
||||
newParentFolderId: string,
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
userId: string,
|
||||
timestamp: Date,
|
||||
folderMapping: Map<string, string>
|
||||
): Promise<void> {
|
||||
const childFolders = await tx
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowFolder.parentId, sourceFolderId),
|
||||
eq(workflowFolder.workspaceId, sourceWorkspaceId),
|
||||
isNull(workflowFolder.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
for (const childFolder of childFolders) {
|
||||
const newChildFolderId = generateId()
|
||||
folderMapping.set(childFolder.id, newChildFolderId)
|
||||
|
||||
await tx.insert(workflowFolder).values({
|
||||
id: newChildFolderId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
name: childFolder.name,
|
||||
color: childFolder.color,
|
||||
parentId: newParentFolderId,
|
||||
sortOrder: childFolder.sortOrder,
|
||||
isExpanded: false,
|
||||
locked: false,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
|
||||
await duplicateFolderStructure(
|
||||
tx,
|
||||
childFolder.id,
|
||||
newChildFolderId,
|
||||
sourceWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
userId,
|
||||
timestamp,
|
||||
folderMapping
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateWorkflowsInFolderTree(
|
||||
tx: DbOrTx,
|
||||
sourceWorkspaceId: string,
|
||||
targetWorkspaceId: string,
|
||||
folderMapping: Map<string, string>,
|
||||
userId: string,
|
||||
requestId: string
|
||||
): Promise<{ total: number; succeeded: number }> {
|
||||
const stats = { total: 0, succeeded: 0 }
|
||||
const workflowsByNewFolder = new Map<string, Array<typeof workflow.$inferSelect>>()
|
||||
const workflowIdMap = new Map<string, string>()
|
||||
|
||||
for (const [oldFolderId, newFolderId] of folderMapping.entries()) {
|
||||
const workflowsInFolder = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(
|
||||
and(
|
||||
eq(workflow.folderId, oldFolderId),
|
||||
eq(workflow.workspaceId, sourceWorkspaceId),
|
||||
isNull(workflow.archivedAt)
|
||||
)
|
||||
)
|
||||
|
||||
stats.total += workflowsInFolder.length
|
||||
workflowsByNewFolder.set(newFolderId, workflowsInFolder)
|
||||
for (const sourceWorkflow of workflowsInFolder) {
|
||||
workflowIdMap.set(sourceWorkflow.id, generateId())
|
||||
}
|
||||
}
|
||||
|
||||
for (const [newFolderId, workflowsInFolder] of workflowsByNewFolder.entries()) {
|
||||
for (const sourceWorkflow of workflowsInFolder) {
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: sourceWorkflow.id,
|
||||
userId,
|
||||
name: sourceWorkflow.name,
|
||||
description: sourceWorkflow.description || undefined,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: newFolderId,
|
||||
requestId,
|
||||
tx,
|
||||
newWorkflowId: workflowIdMap.get(sourceWorkflow.id),
|
||||
workflowIdMap,
|
||||
})
|
||||
|
||||
stats.succeeded++
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { restoreFolderContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('RestoreFolderAPI')
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> }
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(restoreFolderContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { id: folderId } = parsed.data.params
|
||||
const { workspaceId } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await performRestoreFolder({
|
||||
folderId,
|
||||
workspaceId,
|
||||
userId: session.user.id,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info(`Restored folder ${folderId}`, { restoredItems: result.restoredItems })
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_restored',
|
||||
{ folder_id: folderId, workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, restoredItems: result.restoredItems })
|
||||
} catch (error) {
|
||||
logger.error('Error restoring folder', error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Internal server error') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* Tests for individual folder API route (/api/folders/[id])
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
authMockFns,
|
||||
createMockRequest,
|
||||
type MockUser,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
workflowsOrchestrationMock,
|
||||
workflowsOrchestrationMockFns,
|
||||
workflowsUtilsMock,
|
||||
workflowsUtilsMockFns,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockLogger, mockDbRef } = vi.hoisted(() => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
}
|
||||
return {
|
||||
mockLogger: logger,
|
||||
mockDbRef: { current: null as any },
|
||||
}
|
||||
})
|
||||
|
||||
const mockPerformDeleteFolder = workflowsOrchestrationMockFns.mockPerformDeleteFolder
|
||||
const mockPerformUpdateFolder = workflowsOrchestrationMockFns.mockPerformUpdateFolder
|
||||
|
||||
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
|
||||
getRequestContext: () => undefined,
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
vi.mock('@sim/db', () => ({
|
||||
get db() {
|
||||
return mockDbRef.current
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
import { DELETE, PUT } from '@/app/api/folders/[id]/route'
|
||||
|
||||
interface FolderDbMockOptions {
|
||||
folderLookupResult?: any
|
||||
updateResult?: any[]
|
||||
throwError?: boolean
|
||||
circularCheckResults?: any[]
|
||||
}
|
||||
|
||||
const TEST_USER: MockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
}
|
||||
|
||||
const mockFolder = {
|
||||
id: 'folder-1',
|
||||
name: 'Test Folder',
|
||||
userId: TEST_USER.id,
|
||||
workspaceId: 'workspace-123',
|
||||
parentId: null,
|
||||
color: '#6B7280',
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
|
||||
function createFolderDbMock(options: FolderDbMockOptions = {}) {
|
||||
const {
|
||||
folderLookupResult = mockFolder,
|
||||
updateResult = [{ ...mockFolder, name: 'Updated Folder' }],
|
||||
throwError = false,
|
||||
circularCheckResults = [],
|
||||
} = options
|
||||
|
||||
let callCount = 0
|
||||
|
||||
const mockSelect = vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockImplementation(() => ({
|
||||
then: vi.fn().mockImplementation((callback) => {
|
||||
if (throwError) {
|
||||
throw new Error('Database error')
|
||||
}
|
||||
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
const result = folderLookupResult === undefined ? [] : [folderLookupResult]
|
||||
return Promise.resolve(callback(result))
|
||||
}
|
||||
if (callCount > 1 && circularCheckResults.length > 0) {
|
||||
const index = callCount - 2
|
||||
const result = circularCheckResults[index] ? [circularCheckResults[index]] : []
|
||||
return Promise.resolve(callback(result))
|
||||
}
|
||||
return Promise.resolve(callback([]))
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockUpdate = vi.fn().mockImplementation(() => ({
|
||||
set: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockImplementation(() => ({
|
||||
returning: vi.fn().mockReturnValue(updateResult),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockDelete = vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockImplementation(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
return {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function mockAuthenticatedUser(user?: MockUser) {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: user || TEST_USER })
|
||||
}
|
||||
|
||||
function mockUnauthenticated() {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
describe('Individual Folder API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockDbRef.current = createFolderDbMock()
|
||||
mockPerformDeleteFolder.mockResolvedValue({
|
||||
success: true,
|
||||
deletedItems: { folders: 1, workflows: 0 },
|
||||
})
|
||||
mockPerformUpdateFolder.mockImplementation(async (params) => {
|
||||
if (params.parentId && params.parentId === params.folderId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Folder cannot be its own parent',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
if (
|
||||
params.parentId &&
|
||||
(await workflowsUtilsMockFns.mockCheckForCircularReference(
|
||||
params.folderId,
|
||||
params.parentId
|
||||
))
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot create circular folder reference',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
folder: {
|
||||
...mockFolder,
|
||||
id: params.folderId,
|
||||
name: params.name !== undefined ? params.name.trim() : 'Updated Folder',
|
||||
color: params.color ?? mockFolder.color,
|
||||
parentId: params.parentId ?? mockFolder.parentId,
|
||||
isExpanded: params.isExpanded,
|
||||
sortOrder: params.sortOrder ?? mockFolder.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
}
|
||||
})
|
||||
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
describe('PUT /api/folders/[id]', () => {
|
||||
it('should update folder successfully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder Name',
|
||||
color: '#FF0000',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folder')
|
||||
expect(data.folder).toMatchObject({
|
||||
name: 'Updated Folder Name',
|
||||
})
|
||||
})
|
||||
|
||||
it('should update parent folder successfully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
parentId: 'parent-folder-1',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should return 401 for unauthenticated requests', async () => {
|
||||
mockUnauthenticated()
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 403 when user has only read permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Write access required to update folders')
|
||||
})
|
||||
|
||||
it('should allow folder update for write permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folder')
|
||||
})
|
||||
|
||||
it('should allow folder update for admin permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folder')
|
||||
})
|
||||
|
||||
it('should return 400 when trying to set folder as its own parent', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
parentId: 'folder-1',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Folder cannot be its own parent')
|
||||
})
|
||||
|
||||
it('should trim folder name when updating', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: ' Folder With Spaces ',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(data.folder.name).toBe('Folder With Spaces')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
throwError: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Internal server error')
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Error updating folder:', {
|
||||
error: expect.any(Error),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('should handle empty folder name', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: '',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should handle invalid JSON payload', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = new Request('http://localhost:3000/api/folders/folder-1', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: 'invalid-json',
|
||||
}) as any
|
||||
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Circular Reference Prevention', () => {
|
||||
it('should prevent circular references when updating parent', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
folderLookupResult: {
|
||||
id: 'folder-3',
|
||||
parentId: null,
|
||||
name: 'Folder 3',
|
||||
workspaceId: 'workspace-123',
|
||||
},
|
||||
})
|
||||
|
||||
workflowsUtilsMockFns.mockCheckForCircularReference.mockResolvedValue(true)
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
name: 'Updated Folder 3',
|
||||
parentId: 'folder-1',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'folder-3' })
|
||||
|
||||
const response = await PUT(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Cannot create circular folder reference')
|
||||
expect(workflowsUtilsMockFns.mockCheckForCircularReference).toHaveBeenCalledWith(
|
||||
'folder-3',
|
||||
'folder-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/folders/[id]', () => {
|
||||
it('should delete folder and all contents successfully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
folderLookupResult: mockFolder,
|
||||
})
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(data).toHaveProperty('deletedItems')
|
||||
expect(mockPerformDeleteFolder).toHaveBeenCalledWith({
|
||||
folderId: 'folder-1',
|
||||
workspaceId: 'workspace-123',
|
||||
userId: TEST_USER.id,
|
||||
folderName: 'Test Folder',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 401 for unauthenticated delete requests', async () => {
|
||||
mockUnauthenticated()
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 403 when user has only read permissions for delete', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Write or Admin access required to delete folders')
|
||||
})
|
||||
|
||||
it('should allow folder deletion for write permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
folderLookupResult: mockFolder,
|
||||
})
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(mockPerformDeleteFolder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow folder deletion for admin permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
folderLookupResult: mockFolder,
|
||||
})
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('success', true)
|
||||
expect(mockPerformDeleteFolder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle database errors during deletion', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockDbRef.current = createFolderDbMock({
|
||||
throwError: true,
|
||||
})
|
||||
|
||||
const req = createMockRequest('DELETE')
|
||||
const params = Promise.resolve({ id: 'folder-1' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Internal server error')
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Error deleting folder:', {
|
||||
error: expect.any(Error),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateFolderContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteFolder, performUpdateFolder } from '@/lib/workflows/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersIDAPI')
|
||||
|
||||
// PUT - Update a folder
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateFolderContract, request, context, {
|
||||
validationErrorResponse: (error) => {
|
||||
logger.error('Folder update validation failed:', { errors: error.issues })
|
||||
const errorMessages = error.issues
|
||||
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
||||
.join(', ')
|
||||
return NextResponse.json(
|
||||
{ error: `Validation failed: ${errorMessages}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id } = parsed.data.params
|
||||
const { name, color, isExpanded, locked, parentId, sortOrder } = parsed.data.body
|
||||
|
||||
// Verify the folder exists
|
||||
const existingFolder = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!existingFolder) {
|
||||
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has write permissions for the workspace
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
existingFolder.workspaceId
|
||||
)
|
||||
|
||||
if (!workspacePermission || workspacePermission === 'read') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write access required to update folders' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (locked !== undefined && workspacePermission !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin access required to lock folders' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const hasNonLockUpdate = Object.keys(parsed.data.body).some((key) => key !== 'locked')
|
||||
if (hasNonLockUpdate) {
|
||||
await assertFolderMutable(id)
|
||||
}
|
||||
if (parentId !== undefined) {
|
||||
await assertFolderMutable(parentId)
|
||||
}
|
||||
|
||||
const result = await performUpdateFolder({
|
||||
folderId: id,
|
||||
workspaceId: existingFolder.workspaceId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
color,
|
||||
isExpanded,
|
||||
locked,
|
||||
parentId,
|
||||
sortOrder,
|
||||
})
|
||||
|
||||
if (!result.success || !result.folder) {
|
||||
const status =
|
||||
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
logger.info('Updated folder:', { id, updates: parsed.data.body })
|
||||
|
||||
return NextResponse.json({ folder: result.folder })
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error('Error updating folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// DELETE - Delete a folder and all its contents
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verify the folder exists
|
||||
const existingFolder = await db
|
||||
.select()
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!existingFolder) {
|
||||
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
existingFolder.workspaceId
|
||||
)
|
||||
|
||||
if (!workspacePermission || workspacePermission === 'read') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required to delete folders' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await assertFolderMutable(id)
|
||||
|
||||
const result = await performDeleteFolder({
|
||||
folderId: id,
|
||||
workspaceId: existingFolder.workspaceId,
|
||||
userId: session.user.id,
|
||||
folderName: existingFolder.name,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
const status =
|
||||
result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_deleted',
|
||||
{ workspace_id: existingFolder.workspaceId },
|
||||
{ groups: { workspace: existingFolder.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedItems: result.deletedItems,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error('Error deleting folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Tests for the folder reorder API route.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { authMockFns, createMockRequest, permissionsMock, permissionsMockFns } from '@sim/testing'
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockLogger } = vi.hoisted(() => ({
|
||||
mockLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
|
||||
|
||||
vi.mock('drizzle-orm', () => drizzleOrmMock)
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
|
||||
getRequestContext: () => undefined,
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { PUT } from '@/app/api/folders/reorder/route'
|
||||
|
||||
const mockDb = db as any
|
||||
|
||||
describe('PUT /api/folders/reorder', () => {
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockTxUpdate = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
mockDb.select.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
|
||||
mockTxUpdate.mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
})
|
||||
mockDb.transaction.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) =>
|
||||
cb({ update: mockTxUpdate })
|
||||
)
|
||||
})
|
||||
|
||||
it('reorders folders when updates are valid', async () => {
|
||||
mockWhere
|
||||
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
|
||||
.mockReturnValueOnce([{ id: 'folder-1', parentId: null }])
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
workspaceId: 'workspace-123',
|
||||
updates: [{ id: 'folder-1', sortOrder: 2, parentId: null }],
|
||||
})
|
||||
|
||||
const response = await PUT(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data).toMatchObject({ success: true, updated: 1 })
|
||||
})
|
||||
|
||||
it('rejects a parentId that belongs to another workspace', async () => {
|
||||
mockWhere
|
||||
.mockReturnValueOnce([{ id: 'folder-1', workspaceId: 'workspace-123' }])
|
||||
.mockReturnValueOnce([{ id: 'foreign', workspaceId: 'workspace-OTHER', archivedAt: null }])
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
workspaceId: 'workspace-123',
|
||||
updates: [{ id: 'folder-1', sortOrder: 0, parentId: 'foreign' }],
|
||||
})
|
||||
|
||||
const response = await PUT(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Parent folder not found')
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a batch that would form a cycle', async () => {
|
||||
mockWhere
|
||||
.mockReturnValueOnce([
|
||||
{ id: 'A', workspaceId: 'workspace-123' },
|
||||
{ id: 'B', workspaceId: 'workspace-123' },
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{ id: 'A', workspaceId: 'workspace-123', archivedAt: null },
|
||||
{ id: 'B', workspaceId: 'workspace-123', archivedAt: null },
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{ id: 'A', parentId: null },
|
||||
{ id: 'B', parentId: null },
|
||||
])
|
||||
|
||||
const req = createMockRequest('PUT', {
|
||||
workspaceId: 'workspace-123',
|
||||
updates: [
|
||||
{ id: 'A', sortOrder: 0, parentId: 'B' },
|
||||
{ id: 'B', sortOrder: 0, parentId: 'A' },
|
||||
],
|
||||
})
|
||||
|
||||
const response = await PUT(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Cannot create circular folder reference')
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { reorderFoldersContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FolderReorderAPI')
|
||||
|
||||
export const PUT = withRouteHandler(async (req: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized folder reorder attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = await parseRequest(reorderFoldersContract, req, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { workspaceId, updates } = parsed.data.body
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (!permission || permission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const folderIds = updates.map((u) => u.id)
|
||||
const existingFolders = await db
|
||||
.select({ id: workflowFolder.id, workspaceId: workflowFolder.workspaceId })
|
||||
.from(workflowFolder)
|
||||
.where(inArray(workflowFolder.id, folderIds))
|
||||
|
||||
const validIds = new Set(
|
||||
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
|
||||
)
|
||||
|
||||
const validUpdates = updates.filter((u) => validIds.has(u.id))
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid folders to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const targetParentIds = Array.from(
|
||||
new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id)))
|
||||
)
|
||||
|
||||
if (targetParentIds.length > 0) {
|
||||
const parentFolders = await db
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
workspaceId: workflowFolder.workspaceId,
|
||||
archivedAt: workflowFolder.archivedAt,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(inArray(workflowFolder.id, targetParentIds))
|
||||
|
||||
const validParentIds = new Set(
|
||||
parentFolders.filter((f) => f.workspaceId === workspaceId && !f.archivedAt).map((f) => f.id)
|
||||
)
|
||||
|
||||
for (const update of validUpdates) {
|
||||
if (!update.parentId) continue
|
||||
if (update.parentId === update.id) {
|
||||
return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 })
|
||||
}
|
||||
if (!validParentIds.has(update.parentId)) {
|
||||
return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceFolders = await db
|
||||
.select({ id: workflowFolder.id, parentId: workflowFolder.parentId })
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.workspaceId, workspaceId))
|
||||
|
||||
const parentById = new Map<string, string | null>()
|
||||
for (const folder of workspaceFolders) {
|
||||
parentById.set(folder.id, folder.parentId)
|
||||
}
|
||||
for (const update of validUpdates) {
|
||||
if (update.parentId !== undefined) {
|
||||
parentById.set(update.id, update.parentId || null)
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of validUpdates) {
|
||||
const visited = new Set<string>()
|
||||
let cursor: string | null = update.id
|
||||
while (cursor) {
|
||||
if (visited.has(cursor)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot create circular folder reference' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
visited.add(cursor)
|
||||
cursor = parentById.get(cursor) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of validUpdates) {
|
||||
await assertFolderMutable(update.id)
|
||||
if (update.parentId !== undefined) {
|
||||
await assertFolderMutable(update.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of validUpdates) {
|
||||
const updateData: Record<string, unknown> = {
|
||||
sortOrder: update.sortOrder,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
if (update.parentId !== undefined) {
|
||||
updateData.parentId = update.parentId || null
|
||||
}
|
||||
await tx.update(workflowFolder).set(updateData).where(eq(workflowFolder.id, update.id))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Reordered ${validUpdates.length} folders in workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, updated: validUpdates.length })
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reordering folders`, error)
|
||||
return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Tests for folders API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
authMockFns,
|
||||
createMockRequest,
|
||||
permissionsMock,
|
||||
permissionsMockFns,
|
||||
workflowAuthzMockFns,
|
||||
} from '@sim/testing'
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockLogger } = vi.hoisted(() => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
}
|
||||
return {
|
||||
mockLogger: logger,
|
||||
}
|
||||
})
|
||||
|
||||
const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
...drizzleOrmMock,
|
||||
min: vi.fn((field) => ({ type: 'min', field })),
|
||||
}))
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
runWithRequestContext: <T>(_ctx: unknown, fn: () => T): T => fn(),
|
||||
getRequestContext: () => undefined,
|
||||
}))
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { GET, POST } from '@/app/api/folders/route'
|
||||
|
||||
const mockDb = db as any
|
||||
|
||||
interface CapturedFolderValues {
|
||||
name?: string
|
||||
color?: string
|
||||
parentId?: string | null
|
||||
isExpanded?: boolean
|
||||
sortOrder?: number
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
function createMockTransaction(mockData: {
|
||||
selectResults?: Array<Array<{ [key: string]: unknown }>>
|
||||
insertResult?: Array<{ id: string; [key: string]: unknown }>
|
||||
onInsertValues?: (values: CapturedFolderValues) => void
|
||||
}) {
|
||||
const { selectResults = [[], []], insertResult = [], onInsertValues } = mockData
|
||||
return async (callback: (tx: unknown) => Promise<unknown>) => {
|
||||
const where = vi.fn()
|
||||
for (const result of selectResults) {
|
||||
where.mockReturnValueOnce(result)
|
||||
}
|
||||
where.mockReturnValue([])
|
||||
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where,
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: CapturedFolderValues) => {
|
||||
onInsertValues?.(values)
|
||||
return {
|
||||
returning: vi.fn().mockReturnValue(insertResult),
|
||||
}
|
||||
}),
|
||||
}),
|
||||
}
|
||||
return await callback(tx)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultMockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
}
|
||||
|
||||
describe('Folders API Route', () => {
|
||||
const mockFolders = [
|
||||
{
|
||||
id: 'folder-1',
|
||||
name: 'Test Folder 1',
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-123',
|
||||
parentId: null,
|
||||
color: '#6B7280',
|
||||
isExpanded: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2023-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2023-01-01T00:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'folder-2',
|
||||
name: 'Test Folder 2',
|
||||
userId: 'user-123',
|
||||
workspaceId: 'workspace-123',
|
||||
parentId: 'folder-1',
|
||||
color: '#EF4444',
|
||||
isExpanded: false,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2023-01-02T00:00:00.000Z'),
|
||||
updatedAt: new Date('2023-01-02T00:00:00.000Z'),
|
||||
},
|
||||
]
|
||||
|
||||
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
|
||||
|
||||
const mockSelect = mockDb.select
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockLimit = vi.fn()
|
||||
const mockOrderBy = vi.fn()
|
||||
const mockInsert = mockDb.insert
|
||||
const mockValues = vi.fn()
|
||||
const mockReturning = vi.fn()
|
||||
const mockTransaction = mockDb.transaction
|
||||
|
||||
function mockAuthenticatedUser() {
|
||||
authMockFns.mockGetSession.mockResolvedValue({ user: defaultMockUser })
|
||||
}
|
||||
|
||||
function mockUnauthenticated() {
|
||||
authMockFns.mockGetSession.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue(mockUUID),
|
||||
})
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
const defaultWhereResult = [] as Array<Record<string, unknown>> & {
|
||||
orderBy: typeof mockOrderBy
|
||||
limit: typeof mockLimit
|
||||
}
|
||||
defaultWhereResult.orderBy = mockOrderBy
|
||||
defaultWhereResult.limit = mockLimit
|
||||
mockWhere.mockReturnValue(defaultWhereResult)
|
||||
mockLimit.mockReturnValue([])
|
||||
mockOrderBy.mockReturnValue(mockFolders)
|
||||
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
mockValues.mockReturnValue({ returning: mockReturning })
|
||||
mockReturning.mockReturnValue([mockFolders[0]])
|
||||
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
})
|
||||
|
||||
describe('GET /api/folders', () => {
|
||||
it('should return folders for a valid workspace', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const mockRequest = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/folders?workspaceId=workspace-123'
|
||||
)
|
||||
|
||||
const response = await GET(mockRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folders')
|
||||
expect(data.folders).toHaveLength(2)
|
||||
expect(data.folders[0]).toMatchObject({
|
||||
id: 'folder-1',
|
||||
name: 'Test Folder 1',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 401 for unauthenticated requests', async () => {
|
||||
mockUnauthenticated()
|
||||
|
||||
const mockRequest = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/folders?workspaceId=workspace-123'
|
||||
)
|
||||
|
||||
const response = await GET(mockRequest)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 400 when workspaceId is missing', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const mockRequest = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/folders'
|
||||
)
|
||||
|
||||
const response = await GET(mockRequest)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Validation error')
|
||||
expect(data.details?.[0]?.message).toBe('Workspace ID is required')
|
||||
})
|
||||
|
||||
it('should return 403 when user has no workspace permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue(null)
|
||||
|
||||
const mockRequest = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/folders?workspaceId=workspace-123'
|
||||
)
|
||||
|
||||
const response = await GET(mockRequest)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Access denied to this workspace')
|
||||
})
|
||||
|
||||
it('should return 403 when user has only read permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const mockRequest = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/folders?workspaceId=workspace-123'
|
||||
)
|
||||
|
||||
const response = await GET(mockRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folders')
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockSelect.mockImplementationOnce(() => {
|
||||
throw new Error('Database connection failed')
|
||||
})
|
||||
|
||||
const mockRequest = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/folders?workspaceId=workspace-123'
|
||||
)
|
||||
|
||||
const response = await GET(mockRequest)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Internal server error')
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Error fetching folders:', {
|
||||
error: expect.any(Error),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/folders', () => {
|
||||
it('should create a new folder successfully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[], []],
|
||||
insertResult: [mockFolders[0]],
|
||||
})
|
||||
)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'New Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
color: '#6B7280',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const responseBody = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(responseBody).toHaveProperty('folder')
|
||||
expect(responseBody.folder).toMatchObject({
|
||||
id: 'folder-1',
|
||||
name: 'Test Folder 1',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should create folder with correct sort order', async () => {
|
||||
mockAuthenticatedUser()
|
||||
let capturedValues: CapturedFolderValues | null = null
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[{ minSortOrder: 5 }], [{ minSortOrder: 2 }]],
|
||||
insertResult: [{ ...mockFolders[0], sortOrder: 1 }],
|
||||
onInsertValues: (values) => {
|
||||
capturedValues = values
|
||||
},
|
||||
})
|
||||
)
|
||||
mockWhere
|
||||
.mockReturnValueOnce([{ minSortOrder: 5 }])
|
||||
.mockReturnValueOnce([{ minSortOrder: 2 }])
|
||||
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
|
||||
capturedValues = values
|
||||
return { returning: mockReturning }
|
||||
})
|
||||
mockReturning.mockReturnValueOnce([{ ...mockFolders[0], sortOrder: 1 }])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'New Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.folder).toMatchObject({
|
||||
sortOrder: 1,
|
||||
})
|
||||
expect(capturedValues).not.toBeNull()
|
||||
expect(capturedValues!.sortOrder).toBe(1)
|
||||
})
|
||||
|
||||
it('should create subfolder with parent reference', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[], []],
|
||||
insertResult: [{ ...mockFolders[1] }],
|
||||
})
|
||||
)
|
||||
mockLimit.mockReturnValueOnce([{ ...mockFolders[0] }])
|
||||
mockReturning.mockReturnValueOnce([{ ...mockFolders[1] }])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Subfolder',
|
||||
workspaceId: 'workspace-123',
|
||||
parentId: 'folder-1',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data.folder).toMatchObject({
|
||||
parentId: 'folder-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject creating a subfolder inside a locked parent folder', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const { FolderLockedError } = await import('@sim/platform-authz/workflow')
|
||||
workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValueOnce(
|
||||
new FolderLockedError('Folder is locked')
|
||||
)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Subfolder',
|
||||
workspaceId: 'workspace-123',
|
||||
parentId: 'locked-folder',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(423)
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject a parentId that does not resolve to a folder in the workspace', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockLimit.mockReturnValueOnce([])
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Subfolder',
|
||||
workspaceId: 'workspace-123',
|
||||
parentId: 'folder-in-other-workspace',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Parent folder not found')
|
||||
})
|
||||
|
||||
it('should return 401 for unauthenticated requests', async () => {
|
||||
mockUnauthenticated()
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 403 when user has only read permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Write or Admin access required to create folders')
|
||||
})
|
||||
|
||||
it('should allow folder creation for write permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[], []],
|
||||
insertResult: [mockFolders[0]],
|
||||
})
|
||||
)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folder')
|
||||
})
|
||||
|
||||
it('should allow folder creation for admin permissions', async () => {
|
||||
mockAuthenticatedUser()
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[], []],
|
||||
insertResult: [mockFolders[0]],
|
||||
})
|
||||
)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('folder')
|
||||
})
|
||||
|
||||
it('should return 400 when required fields are missing', async () => {
|
||||
const testCases = [
|
||||
{ name: '', workspaceId: 'workspace-123' },
|
||||
{ name: 'Test Folder', workspaceId: '' },
|
||||
{ workspaceId: 'workspace-123' },
|
||||
{ name: 'Test Folder' },
|
||||
]
|
||||
|
||||
for (const body of testCases) {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
const req = createMockRequest('POST', body)
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Validation error')
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
mockInsert.mockImplementationOnce(() => {
|
||||
throw new Error('Database insert failed')
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toHaveProperty('error', 'Internal server error')
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Failed to create workflow folder', {
|
||||
error: expect.any(Error),
|
||||
})
|
||||
})
|
||||
|
||||
it('should trim folder name when creating', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
let capturedValues: CapturedFolderValues | null = null
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[], []],
|
||||
insertResult: [mockFolders[0]],
|
||||
onInsertValues: (values) => {
|
||||
capturedValues = values
|
||||
},
|
||||
})
|
||||
)
|
||||
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
|
||||
capturedValues = values
|
||||
return { returning: mockReturning }
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: ' Test Folder With Spaces ',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
await POST(req)
|
||||
|
||||
expect(capturedValues).not.toBeNull()
|
||||
expect(capturedValues!.name).toBe('Test Folder With Spaces')
|
||||
})
|
||||
|
||||
it('should use default color when not provided', async () => {
|
||||
mockAuthenticatedUser()
|
||||
|
||||
let capturedValues: CapturedFolderValues | null = null
|
||||
|
||||
mockTransaction.mockImplementationOnce(
|
||||
createMockTransaction({
|
||||
selectResults: [[], []],
|
||||
insertResult: [mockFolders[0]],
|
||||
onInsertValues: (values) => {
|
||||
capturedValues = values
|
||||
},
|
||||
})
|
||||
)
|
||||
mockValues.mockImplementationOnce((values: CapturedFolderValues) => {
|
||||
capturedValues = values
|
||||
return { returning: mockReturning }
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
name: 'Test Folder',
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
|
||||
await POST(req)
|
||||
|
||||
expect(capturedValues).not.toBeNull()
|
||||
expect(capturedValues!.color).toBe('#6B7280')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createFolderContract, listFoldersContract } from '@/lib/api/contracts'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { listFoldersForWorkspace } from '@/lib/folders/queries'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performCreateFolder } from '@/lib/workflows/orchestration'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
|
||||
function folderMutationStatus(errorCode: string | undefined): number {
|
||||
if (errorCode === 'validation') return 400
|
||||
if (errorCode === 'conflict') return 409
|
||||
if (errorCode === 'not_found') return 404
|
||||
return 500
|
||||
}
|
||||
|
||||
// GET - Fetch folders for a workspace
|
||||
export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(listFoldersContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { workspaceId, scope } = parsed.data.query
|
||||
|
||||
// Check if user has workspace permissions
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
|
||||
if (!workspacePermission) {
|
||||
return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 })
|
||||
}
|
||||
|
||||
const folders = await listFoldersForWorkspace(workspaceId, scope)
|
||||
|
||||
return NextResponse.json({ folders })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching folders:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
// POST - Create a new folder
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(createFolderContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const {
|
||||
id: clientId,
|
||||
name,
|
||||
workspaceId,
|
||||
parentId,
|
||||
color,
|
||||
sortOrder: providedSortOrder,
|
||||
} = parsed.data.body
|
||||
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
)
|
||||
|
||||
if (!workspacePermission || workspacePermission === 'read') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required to create folders' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await assertFolderMutable(parentId ?? null)
|
||||
|
||||
const result = await performCreateFolder({
|
||||
id: clientId,
|
||||
userId: session.user.id,
|
||||
workspaceId,
|
||||
name,
|
||||
parentId,
|
||||
color,
|
||||
sortOrder: providedSortOrder,
|
||||
})
|
||||
|
||||
if (!result.success || !result.folder) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: folderMutationStatus(result.errorCode) }
|
||||
)
|
||||
}
|
||||
|
||||
const newFolder = result.folder
|
||||
|
||||
logger.info('Created new folder:', { id: newFolder.id, name, workspaceId, parentId })
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ folder: newFolder })
|
||||
} catch (error) {
|
||||
if (error instanceof FolderLockedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
logger.error('Error creating folder:', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user