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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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 }
)
}
})
+532
View File
@@ -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),
})
})
})
})
+185
View File
@@ -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()
})
})
+146
View File
@@ -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 })
}
})
+618
View File
@@ -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')
})
})
})
+124
View File
@@ -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 })
}
})