diff --git a/.claude/rules/global.md b/.claude/rules/global.md index b80c3695ce3..e4851bcc439 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -1,7 +1,10 @@ # Global Standards ## Logging -Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. +Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID. + +## API Route Handlers +All API route handlers must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`. ## Comments Use TSDoc for documentation. No `====` separators. No non-TSDoc comments. diff --git a/CLAUDE.md b/CLAUDE.md index bc54c6f912c..fa613e294ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ You are a professional software engineer. All code must follow best practices: a ## Global Standards -- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log` +- **Logging**: Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`. Inside API routes wrapped with `withRouteHandler`, loggers automatically include the request ID — no manual `withMetadata({ requestId })` needed +- **API Route Handlers**: All API route handlers (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`) must be wrapped with `withRouteHandler` from `@/lib/core/utils/with-route-handler`. This provides request ID tracking, automatic error logging for 4xx/5xx responses, and unhandled error catching. See "API Route Pattern" section below - **Comments**: Use TSDoc for documentation. No `====` separators. No non-TSDoc comments - **Styling**: Never update global styles. Keep all styling local to components - **ID Generation**: Never use `crypto.randomUUID()`, `nanoid`, or `uuid` package. Use `generateId()` (UUID v4) or `generateShortId()` (compact) from `@/lib/core/utils/uuid` @@ -92,6 +93,41 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational. +## API Route Pattern + +Every API route handler must be wrapped with `withRouteHandler`. This sets up `AsyncLocalStorage`-based request context so all loggers in the request lifecycle automatically include the request ID. + +```typescript +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('MyAPI') + +// Simple route +export const GET = withRouteHandler(async (request: NextRequest) => { + logger.info('Handling request') // automatically includes {requestId=...} + return NextResponse.json({ ok: true }) +}) + +// Route with params +export const DELETE = withRouteHandler(async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) => { + const { id } = await params + return NextResponse.json({ deleted: id }) +}) + +// Composing with other middleware (withRouteHandler wraps the outermost layer) +export const POST = withRouteHandler(withAdminAuth(async (request) => { + return NextResponse.json({ ok: true }) +})) +``` + +Never export a bare `async function GET/POST/...` — always use `export const METHOD = withRouteHandler(...)`. + ## Hooks ```typescript diff --git a/apps/sim/app/api/a2a/agents/[agentId]/route.ts b/apps/sim/app/api/a2a/agents/[agentId]/route.ts index 877093ae30a..d3d6c19ff7f 100644 --- a/apps/sim/app/api/a2a/agents/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/agents/[agentId]/route.ts @@ -7,6 +7,7 @@ import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-c import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getRedisClient } from '@/lib/core/config/redis' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -22,250 +23,176 @@ interface RouteParams { /** * GET - Returns the Agent Card for discovery */ -export async function GET(request: NextRequest, { params }: { params: Promise }) { - const { agentId } = await params - - try { - const [agent] = await db - .select({ - agent: a2aAgent, - workflow: workflow, - }) - .from(a2aAgent) - .innerJoin(workflow, and(eq(a2aAgent.workflowId, workflow.id), isNull(workflow.archivedAt))) - .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) - .limit(1) - - if (!agent) { - return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { agentId } = await params + + try { + const [agent] = await db + .select({ + agent: a2aAgent, + workflow: workflow, + }) + .from(a2aAgent) + .innerJoin(workflow, and(eq(a2aAgent.workflowId, workflow.id), isNull(workflow.archivedAt))) + .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) + .limit(1) - if (!agent.agent.isPublished) { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Agent not published' }, { status: 404 }) + if (!agent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) } - const workspaceAccess = await checkWorkspaceAccess(agent.agent.workspaceId, auth.userId) - if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { - return NextResponse.json({ error: 'Agent not published' }, { status: 404 }) - } - } + if (!agent.agent.isPublished) { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Agent not published' }, { status: 404 }) + } - const agentCard = generateAgentCard( - { - id: agent.agent.id, - name: agent.agent.name, - description: agent.agent.description, - version: agent.agent.version, - capabilities: agent.agent.capabilities as AgentCapabilities, - skills: agent.agent.skills as AgentSkill[], - }, - { - id: agent.workflow.id, - name: agent.workflow.name, - description: agent.workflow.description, + const workspaceAccess = await checkWorkspaceAccess(agent.agent.workspaceId, auth.userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + return NextResponse.json({ error: 'Agent not published' }, { status: 404 }) + } } - ) - - return NextResponse.json(agentCard, { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache', - }, - }) - } catch (error) { - logger.error('Error getting Agent Card:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + + const agentCard = generateAgentCard( + { + id: agent.agent.id, + name: agent.agent.name, + description: agent.agent.description, + version: agent.agent.version, + capabilities: agent.agent.capabilities as AgentCapabilities, + skills: agent.agent.skills as AgentSkill[], + }, + { + id: agent.workflow.id, + name: agent.workflow.name, + description: agent.workflow.description, + } + ) + + return NextResponse.json(agentCard, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': agent.agent.isPublished ? 'public, max-age=3600' : 'private, no-cache', + }, + }) + } catch (error) { + logger.error('Error getting Agent Card:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * PUT - Update an agent */ -export async function PUT(request: NextRequest, { params }: { params: Promise }) { - const { agentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const [existingAgent] = await db - .select() - .from(a2aAgent) - .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) - .limit(1) +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { agentId } = await params - if (!existingAgent) { - return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) - } - - const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const body = await request.json() - - if ( - body.skillTags !== undefined && - (!Array.isArray(body.skillTags) || - !body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string')) - ) { - return NextResponse.json({ error: 'skillTags must be an array of strings' }, { status: 400 }) - } + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - let skills = body.skills ?? existingAgent.skills - if (body.skillTags !== undefined) { - const agentName = body.name ?? existingAgent.name - const agentDescription = body.description ?? existingAgent.description - skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags) - } + const [existingAgent] = await db + .select() + .from(a2aAgent) + .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) + .limit(1) - const [updatedAgent] = await db - .update(a2aAgent) - .set({ - name: body.name ?? existingAgent.name, - description: body.description ?? existingAgent.description, - version: body.version ?? existingAgent.version, - capabilities: body.capabilities ?? existingAgent.capabilities, - skills, - authentication: body.authentication ?? existingAgent.authentication, - isPublished: body.isPublished ?? existingAgent.isPublished, - publishedAt: - body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt, - updatedAt: new Date(), - }) - .where(eq(a2aAgent.id, agentId)) - .returning() + if (!existingAgent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) + } - logger.info(`Updated A2A agent: ${agentId}`) + const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId) + if (!workspaceAccess.canWrite) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - return NextResponse.json({ success: true, agent: updatedAgent }) - } catch (error) { - logger.error('Error updating agent:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} + const body = await request.json() -/** - * DELETE - Delete an agent - */ -export async function DELETE(request: NextRequest, { params }: { params: Promise }) { - const { agentId } = await params + if ( + body.skillTags !== undefined && + (!Array.isArray(body.skillTags) || + !body.skillTags.every((tag: unknown): tag is string => typeof tag === 'string')) + ) { + return NextResponse.json( + { error: 'skillTags must be an array of strings' }, + { status: 400 } + ) + } - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + let skills = body.skills ?? existingAgent.skills + if (body.skillTags !== undefined) { + const agentName = body.name ?? existingAgent.name + const agentDescription = body.description ?? existingAgent.description + skills = generateSkillsFromWorkflow(agentName, agentDescription, body.skillTags) + } - const [existingAgent] = await db - .select() - .from(a2aAgent) - .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) - .limit(1) + const [updatedAgent] = await db + .update(a2aAgent) + .set({ + name: body.name ?? existingAgent.name, + description: body.description ?? existingAgent.description, + version: body.version ?? existingAgent.version, + capabilities: body.capabilities ?? existingAgent.capabilities, + skills, + authentication: body.authentication ?? existingAgent.authentication, + isPublished: body.isPublished ?? existingAgent.isPublished, + publishedAt: + body.isPublished && !existingAgent.isPublished ? new Date() : existingAgent.publishedAt, + updatedAt: new Date(), + }) + .where(eq(a2aAgent.id, agentId)) + .returning() - if (!existingAgent) { - return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) - } + logger.info(`Updated A2A agent: ${agentId}`) - const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json({ success: true, agent: updatedAgent }) + } catch (error) { + logger.error('Error updating agent:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId)) - - logger.info(`Deleted A2A agent: ${agentId}`) - - captureServerEvent( - auth.userId, - 'a2a_agent_deleted', - { - agent_id: agentId, - workflow_id: existingAgent.workflowId, - workspace_id: existingAgent.workspaceId, - }, - { groups: { workspace: existingAgent.workspaceId } } - ) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting agent:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) /** - * POST - Publish/unpublish an agent + * DELETE - Delete an agent */ -export async function POST(request: NextRequest, { params }: { params: Promise }) { - const { agentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn('A2A agent publish auth failed:', { error: auth.error, hasUserId: !!auth.userId }) - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - const [existingAgent] = await db - .select() - .from(a2aAgent) - .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) - .limit(1) - - if (!existingAgent) { - return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { agentId } = await params - const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const body = await request.json() - const action = body.action as 'publish' | 'unpublish' | 'refresh' + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (action === 'publish') { - const [wf] = await db - .select({ isDeployed: workflow.isDeployed }) - .from(workflow) - .where(eq(workflow.id, existingAgent.workflowId)) + const [existingAgent] = await db + .select() + .from(a2aAgent) + .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) .limit(1) - if (!wf?.isDeployed) { - return NextResponse.json( - { error: 'Workflow must be deployed before publishing agent' }, - { status: 400 } - ) + if (!existingAgent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) } - await db - .update(a2aAgent) - .set({ - isPublished: true, - publishedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(a2aAgent.id, agentId)) - - const redis = getRedisClient() - if (redis) { - try { - await redis.del(`a2a:agent:${agentId}:card`) - } catch (err) { - logger.warn('Failed to invalidate agent card cache', { agentId, error: err }) - } + const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId) + if (!workspaceAccess.canWrite) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - logger.info(`Published A2A agent: ${agentId}`) + await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId)) + + logger.info(`Deleted A2A agent: ${agentId}`) + captureServerEvent( auth.userId, - 'a2a_agent_published', + 'a2a_agent_deleted', { agent_id: agentId, workflow_id: existingAgent.workflowId, @@ -273,70 +200,158 @@ export async function POST(request: NextRequest, { params }: { params: Promise }) => { + const { agentId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn('A2A agent publish auth failed:', { + error: auth.error, + hasUserId: !!auth.userId, }) - .where(eq(a2aAgent.id, agentId)) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const [existingAgent] = await db + .select() + .from(a2aAgent) + .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) + .limit(1) - const redis = getRedisClient() - if (redis) { - try { - await redis.del(`a2a:agent:${agentId}:card`) - } catch (err) { - logger.warn('Failed to invalidate agent card cache', { agentId, error: err }) + if (!existingAgent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) + } + + const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId) + if (!workspaceAccess.canWrite) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await request.json() + const action = body.action as 'publish' | 'unpublish' | 'refresh' + + if (action === 'publish') { + const [wf] = await db + .select({ isDeployed: workflow.isDeployed }) + .from(workflow) + .where(eq(workflow.id, existingAgent.workflowId)) + .limit(1) + + if (!wf?.isDeployed) { + return NextResponse.json( + { error: 'Workflow must be deployed before publishing agent' }, + { status: 400 } + ) + } + + await db + .update(a2aAgent) + .set({ + isPublished: true, + publishedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(a2aAgent.id, agentId)) + + const redis = getRedisClient() + if (redis) { + try { + await redis.del(`a2a:agent:${agentId}:card`) + } catch (err) { + logger.warn('Failed to invalidate agent card cache', { agentId, error: err }) + } } + + logger.info(`Published A2A agent: ${agentId}`) + captureServerEvent( + auth.userId, + 'a2a_agent_published', + { + agent_id: agentId, + workflow_id: existingAgent.workflowId, + workspace_id: existingAgent.workspaceId, + }, + { groups: { workspace: existingAgent.workspaceId } } + ) + return NextResponse.json({ success: true, isPublished: true }) } - logger.info(`Unpublished A2A agent: ${agentId}`) - captureServerEvent( - auth.userId, - 'a2a_agent_unpublished', - { - agent_id: agentId, - workflow_id: existingAgent.workflowId, - workspace_id: existingAgent.workspaceId, - }, - { groups: { workspace: existingAgent.workspaceId } } - ) - return NextResponse.json({ success: true, isPublished: false }) - } + if (action === 'unpublish') { + await db + .update(a2aAgent) + .set({ + isPublished: false, + updatedAt: new Date(), + }) + .where(eq(a2aAgent.id, agentId)) + + const redis = getRedisClient() + if (redis) { + try { + await redis.del(`a2a:agent:${agentId}:card`) + } catch (err) { + logger.warn('Failed to invalidate agent card cache', { agentId, error: err }) + } + } - if (action === 'refresh') { - const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId) - if (!workflowData) { - return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 }) + logger.info(`Unpublished A2A agent: ${agentId}`) + captureServerEvent( + auth.userId, + 'a2a_agent_unpublished', + { + agent_id: agentId, + workflow_id: existingAgent.workflowId, + workspace_id: existingAgent.workspaceId, + }, + { groups: { workspace: existingAgent.workspaceId } } + ) + return NextResponse.json({ success: true, isPublished: false }) } - const [wf] = await db - .select({ name: workflow.name, description: workflow.description }) - .from(workflow) - .where(eq(workflow.id, existingAgent.workflowId)) - .limit(1) + if (action === 'refresh') { + const workflowData = await loadWorkflowFromNormalizedTables(existingAgent.workflowId) + if (!workflowData) { + return NextResponse.json({ error: 'Failed to load workflow' }, { status: 500 }) + } - const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description) + const [wf] = await db + .select({ name: workflow.name, description: workflow.description }) + .from(workflow) + .where(eq(workflow.id, existingAgent.workflowId)) + .limit(1) - await db - .update(a2aAgent) - .set({ - skills, - updatedAt: new Date(), - }) - .where(eq(a2aAgent.id, agentId)) + const skills = generateSkillsFromWorkflow(wf?.name || existingAgent.name, wf?.description) - logger.info(`Refreshed skills for A2A agent: ${agentId}`) - return NextResponse.json({ success: true, skills }) - } + await db + .update(a2aAgent) + .set({ + skills, + updatedAt: new Date(), + }) + .where(eq(a2aAgent.id, agentId)) - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) - } catch (error) { - logger.error('Error with agent action:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.info(`Refreshed skills for A2A agent: ${agentId}`) + return NextResponse.json({ success: true, skills }) + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + } catch (error) { + logger.error('Error with agent action:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/a2a/agents/route.ts b/apps/sim/app/api/a2a/agents/route.ts index e1f82e81047..3c2529ffcd2 100644 --- a/apps/sim/app/api/a2a/agents/route.ts +++ b/apps/sim/app/api/a2a/agents/route.ts @@ -14,6 +14,7 @@ import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants' import { sanitizeAgentName } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' @@ -26,7 +27,7 @@ export const dynamic = 'force-dynamic' /** * GET - List all A2A agents for a workspace */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -84,12 +85,12 @@ export async function GET(request: NextRequest) { logger.error('Error listing agents:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) /** * POST - Create a new A2A agent from a workflow */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -217,4 +218,4 @@ export async function POST(request: NextRequest) { logger.error('Error creating agent:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/a2a/serve/[agentId]/route.ts b/apps/sim/app/api/a2a/serve/[agentId]/route.ts index 911884c1d12..a322674fc1c 100644 --- a/apps/sim/app/api/a2a/serve/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/serve/[agentId]/route.ts @@ -19,6 +19,7 @@ import { getClientIp } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { markExecutionCancelled } from '@/lib/execution/cancellation' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' @@ -71,298 +72,310 @@ function hasCallerAccessToTask( /** * GET - Returns the Agent Card (discovery document) */ -export async function GET(_request: NextRequest, { params }: { params: Promise }) { - const { agentId } = await params +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise }) => { + const { agentId } = await params - const redis = getRedisClient() - const cacheKey = `a2a:agent:${agentId}:card` + const redis = getRedisClient() + const cacheKey = `a2a:agent:${agentId}:card` - if (redis) { - try { - const cached = await redis.get(cacheKey) - if (cached) { - return NextResponse.json(JSON.parse(cached), { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'private, max-age=60', - 'X-Cache': 'HIT', - }, - }) + if (redis) { + try { + const cached = await redis.get(cacheKey) + if (cached) { + return NextResponse.json(JSON.parse(cached), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'private, max-age=60', + 'X-Cache': 'HIT', + }, + }) + } + } catch (err) { + logger.warn('Redis cache read failed', { agentId, error: err }) } - } catch (err) { - logger.warn('Redis cache read failed', { agentId, error: err }) } - } - try { - const [agent] = await db - .select({ - id: a2aAgent.id, - name: a2aAgent.name, - description: a2aAgent.description, - version: a2aAgent.version, - capabilities: a2aAgent.capabilities, - skills: a2aAgent.skills, - authentication: a2aAgent.authentication, - isPublished: a2aAgent.isPublished, - }) - .from(a2aAgent) - .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) - .limit(1) + try { + const [agent] = await db + .select({ + id: a2aAgent.id, + name: a2aAgent.name, + description: a2aAgent.description, + version: a2aAgent.version, + capabilities: a2aAgent.capabilities, + skills: a2aAgent.skills, + authentication: a2aAgent.authentication, + isPublished: a2aAgent.isPublished, + }) + .from(a2aAgent) + .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) + .limit(1) - if (!agent) { - return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) - } + if (!agent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }) + } - if (!agent.isPublished) { - return NextResponse.json({ error: 'Agent not published' }, { status: 404 }) - } + if (!agent.isPublished) { + return NextResponse.json({ error: 'Agent not published' }, { status: 404 }) + } - const baseUrl = getBaseUrl() - const brandConfig = getBrandConfig() - - const authConfig = agent.authentication as { schemes?: string[] } | undefined - const schemes = authConfig?.schemes || [] - const isPublic = schemes.includes('none') - - const agentCard = { - protocolVersion: '0.3.0', - name: agent.name, - description: agent.description || '', - url: `${baseUrl}/api/a2a/serve/${agent.id}`, - version: agent.version, - preferredTransport: 'JSONRPC', - documentationUrl: `${baseUrl}/docs/a2a`, - provider: { - organization: brandConfig.name, - url: baseUrl, - }, - capabilities: agent.capabilities, - skills: agent.skills || [], - ...(isPublic - ? {} - : { - securitySchemes: { - apiKey: { - type: 'apiKey' as const, - name: 'X-API-Key', - in: 'header' as const, - description: 'API key authentication', + const baseUrl = getBaseUrl() + const brandConfig = getBrandConfig() + + const authConfig = agent.authentication as { schemes?: string[] } | undefined + const schemes = authConfig?.schemes || [] + const isPublic = schemes.includes('none') + + const agentCard = { + protocolVersion: '0.3.0', + name: agent.name, + description: agent.description || '', + url: `${baseUrl}/api/a2a/serve/${agent.id}`, + version: agent.version, + preferredTransport: 'JSONRPC', + documentationUrl: `${baseUrl}/docs/a2a`, + provider: { + organization: brandConfig.name, + url: baseUrl, + }, + capabilities: agent.capabilities, + skills: agent.skills || [], + ...(isPublic + ? {} + : { + securitySchemes: { + apiKey: { + type: 'apiKey' as const, + name: 'X-API-Key', + in: 'header' as const, + description: 'API key authentication', + }, }, - }, - security: [{ apiKey: [] }], - }), - defaultInputModes: ['text/plain', 'application/json'], - defaultOutputModes: ['text/plain', 'application/json'], - } + security: [{ apiKey: [] }], + }), + defaultInputModes: ['text/plain', 'application/json'], + defaultOutputModes: ['text/plain', 'application/json'], + } - if (redis) { - try { - await redis.set(cacheKey, JSON.stringify(agentCard), 'EX', 60) - } catch (err) { - logger.warn('Redis cache write failed', { agentId, error: err }) + if (redis) { + try { + await redis.set(cacheKey, JSON.stringify(agentCard), 'EX', 60) + } catch (err) { + logger.warn('Redis cache write failed', { agentId, error: err }) + } } - } - return NextResponse.json(agentCard, { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'private, max-age=60', - 'X-Cache': 'MISS', - }, - }) - } catch (error) { - logger.error('Error getting Agent Card:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json(agentCard, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'private, max-age=60', + 'X-Cache': 'MISS', + }, + }) + } catch (error) { + logger.error('Error getting Agent Card:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * POST - Handle JSON-RPC requests */ -export async function POST(request: NextRequest, { params }: { params: Promise }) { - const { agentId } = await params - - try { - const [agent] = await db - .select({ - id: a2aAgent.id, - name: a2aAgent.name, - workflowId: a2aAgent.workflowId, - workspaceId: a2aAgent.workspaceId, - isPublished: a2aAgent.isPublished, - capabilities: a2aAgent.capabilities, - authentication: a2aAgent.authentication, - }) - .from(a2aAgent) - .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) - .limit(1) - - if (!agent) { - return NextResponse.json( - createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not found'), - { status: 404 } - ) - } - - if (!agent.isPublished) { - return NextResponse.json( - createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not published'), - { status: 404 } - ) - } +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { agentId } = await params - const authSchemes = (agent.authentication as { schemes?: string[] })?.schemes || [] - const requiresAuth = !authSchemes.includes('none') - let authenticatedUserId: string | null = null - let authenticatedAuthType: AuthResult['authType'] - let authenticatedApiKeyType: AuthResult['apiKeyType'] + try { + const [agent] = await db + .select({ + id: a2aAgent.id, + name: a2aAgent.name, + workflowId: a2aAgent.workflowId, + workspaceId: a2aAgent.workspaceId, + isPublished: a2aAgent.isPublished, + capabilities: a2aAgent.capabilities, + authentication: a2aAgent.authentication, + }) + .from(a2aAgent) + .where(and(eq(a2aAgent.id, agentId), isNull(a2aAgent.archivedAt))) + .limit(1) - if (requiresAuth) { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { + if (!agent) { return NextResponse.json( - createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Unauthorized'), - { status: 401 } + createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not found'), + { status: 404 } ) } - authenticatedUserId = auth.userId - authenticatedAuthType = auth.authType - authenticatedApiKeyType = auth.apiKeyType - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== agent.workspaceId) { + if (!agent.isPublished) { return NextResponse.json( - createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'), - { status: 403 } + createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Agent not published'), + { status: 404 } ) } - const workspaceAccess = await checkWorkspaceAccess(agent.workspaceId, authenticatedUserId) - if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { - return NextResponse.json( - createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'), - { status: 403 } - ) + const authSchemes = (agent.authentication as { schemes?: string[] })?.schemes || [] + const requiresAuth = !authSchemes.includes('none') + let authenticatedUserId: string | null = null + let authenticatedAuthType: AuthResult['authType'] + let authenticatedApiKeyType: AuthResult['apiKeyType'] + + if (requiresAuth) { + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json( + createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Unauthorized'), + { status: 401 } + ) + } + authenticatedUserId = auth.userId + authenticatedAuthType = auth.authType + authenticatedApiKeyType = auth.apiKeyType + + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== agent.workspaceId) { + return NextResponse.json( + createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'), + { status: 403 } + ) + } + + const workspaceAccess = await checkWorkspaceAccess(agent.workspaceId, authenticatedUserId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + return NextResponse.json( + createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'), + { status: 403 } + ) + } } - } - const [wf] = await db - .select({ isDeployed: workflow.isDeployed }) - .from(workflow) - .where(and(eq(workflow.id, agent.workflowId), isNull(workflow.archivedAt))) - .limit(1) + const [wf] = await db + .select({ isDeployed: workflow.isDeployed }) + .from(workflow) + .where(and(eq(workflow.id, agent.workflowId), isNull(workflow.archivedAt))) + .limit(1) - if (!wf?.isDeployed) { - return NextResponse.json( - createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Workflow is not deployed'), - { status: 400 } - ) - } + if (!wf?.isDeployed) { + return NextResponse.json( + createError(null, A2A_ERROR_CODES.AGENT_UNAVAILABLE, 'Workflow is not deployed'), + { status: 400 } + ) + } - const body = await request.json() + const body = await request.json() - if (!isJSONRPCRequest(body)) { - return NextResponse.json( - createError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request'), - { status: 400 } - ) - } + if (!isJSONRPCRequest(body)) { + return NextResponse.json( + createError(null, A2A_ERROR_CODES.INVALID_REQUEST, 'Invalid JSON-RPC request'), + { status: 400 } + ) + } - const { id, method, params: rpcParams } = body - const requestApiKey = request.headers.get('X-API-Key') - const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null - const isPersonalApiKeyCaller = - authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal' - const callerFingerprint = getCallerFingerprint(request, authenticatedUserId) - const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId) - if (!billedUserId) { - logger.error('Unable to resolve workspace billed account for A2A execution', { - agentId: agent.id, - workspaceId: agent.workspaceId, - }) - return NextResponse.json( - createError( - id, - A2A_ERROR_CODES.INTERNAL_ERROR, - 'Unable to resolve billing account for this workspace' - ), - { status: 500 } - ) - } - const executionUserId = - isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId + const { id, method, params: rpcParams } = body + const requestApiKey = request.headers.get('X-API-Key') + const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null + const isPersonalApiKeyCaller = + authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal' + const callerFingerprint = getCallerFingerprint(request, authenticatedUserId) + const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId) + if (!billedUserId) { + logger.error('Unable to resolve workspace billed account for A2A execution', { + agentId: agent.id, + workspaceId: agent.workspaceId, + }) + return NextResponse.json( + createError( + id, + A2A_ERROR_CODES.INTERNAL_ERROR, + 'Unable to resolve billing account for this workspace' + ), + { status: 500 } + ) + } + const executionUserId = + isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId - logger.info(`A2A request: ${method} for agent ${agentId}`) + logger.info(`A2A request: ${method} for agent ${agentId}`) - switch (method) { - case A2A_METHODS.MESSAGE_SEND: - return handleMessageSend( - id, - agent, - rpcParams as MessageSendParams, - apiKey, - executionUserId, - callerFingerprint - ) + switch (method) { + case A2A_METHODS.MESSAGE_SEND: + return handleMessageSend( + id, + agent, + rpcParams as MessageSendParams, + apiKey, + executionUserId, + callerFingerprint + ) - case A2A_METHODS.MESSAGE_STREAM: - return handleMessageStream( - request, - id, - agent, - rpcParams as MessageSendParams, - apiKey, - executionUserId, - callerFingerprint - ) + case A2A_METHODS.MESSAGE_STREAM: + return handleMessageStream( + request, + id, + agent, + rpcParams as MessageSendParams, + apiKey, + executionUserId, + callerFingerprint + ) - case A2A_METHODS.TASKS_GET: - return handleTaskGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) + case A2A_METHODS.TASKS_GET: + return handleTaskGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) - case A2A_METHODS.TASKS_CANCEL: - return handleTaskCancel(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) + case A2A_METHODS.TASKS_CANCEL: + return handleTaskCancel(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) - case A2A_METHODS.TASKS_RESUBSCRIBE: - return handleTaskResubscribe( - request, - id, - agent.id, - rpcParams as TaskIdParams, - callerFingerprint - ) + case A2A_METHODS.TASKS_RESUBSCRIBE: + return handleTaskResubscribe( + request, + id, + agent.id, + rpcParams as TaskIdParams, + callerFingerprint + ) - case A2A_METHODS.PUSH_NOTIFICATION_SET: - return handlePushNotificationSet( - id, - agent.id, - rpcParams as PushNotificationSetParams, - callerFingerprint - ) + case A2A_METHODS.PUSH_NOTIFICATION_SET: + return handlePushNotificationSet( + id, + agent.id, + rpcParams as PushNotificationSetParams, + callerFingerprint + ) - case A2A_METHODS.PUSH_NOTIFICATION_GET: - return handlePushNotificationGet(id, agent.id, rpcParams as TaskIdParams, callerFingerprint) + case A2A_METHODS.PUSH_NOTIFICATION_GET: + return handlePushNotificationGet( + id, + agent.id, + rpcParams as TaskIdParams, + callerFingerprint + ) - case A2A_METHODS.PUSH_NOTIFICATION_DELETE: - return handlePushNotificationDelete( - id, - agent.id, - rpcParams as TaskIdParams, - callerFingerprint - ) + case A2A_METHODS.PUSH_NOTIFICATION_DELETE: + return handlePushNotificationDelete( + id, + agent.id, + rpcParams as TaskIdParams, + callerFingerprint + ) - default: - return NextResponse.json( - createError(id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${method}`), - { status: 404 } - ) + default: + return NextResponse.json( + createError(id, A2A_ERROR_CODES.METHOD_NOT_FOUND, `Method not found: ${method}`), + { status: 404 } + ) + } + } catch (error) { + logger.error('Error handling A2A request:', error) + return NextResponse.json( + createError(null, A2A_ERROR_CODES.INTERNAL_ERROR, 'Internal error'), + { + status: 500, + } + ) } - } catch (error) { - logger.error('Error handling A2A request:', error) - return NextResponse.json(createError(null, A2A_ERROR_CODES.INTERNAL_ERROR, 'Internal error'), { - status: 500, - }) } -} +) async function getTaskForAgent(taskId: string, agentId: string, callerFingerprint?: string) { const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, taskId)).limit(1) diff --git a/apps/sim/app/api/academy/certificates/route.ts b/apps/sim/app/api/academy/certificates/route.ts index 0164e1424e3..06e0153d00d 100644 --- a/apps/sim/app/api/academy/certificates/route.ts +++ b/apps/sim/app/api/academy/certificates/route.ts @@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('AcademyCertificatesAPI') @@ -31,7 +32,7 @@ const IssueCertificateSchema = z.object({ * Completion is client-attested: the client sends completed lesson IDs and the server * validates them against the full lesson list for the course. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -150,7 +151,7 @@ export async function POST(req: NextRequest) { logger.error('Failed to issue certificate', { error }) return NextResponse.json({ error: 'Failed to issue certificate' }, { status: 500 }) } -} +}) /** * GET /api/academy/certificates?certificateNumber=SIM-2026-00042 @@ -159,7 +160,7 @@ export async function POST(req: NextRequest) { * GET /api/academy/certificates?courseId=... * Authenticated endpoint for looking up the current user's certificate for a course. */ -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { try { const { searchParams } = new URL(req.url) const certificateNumber = searchParams.get('certificateNumber') @@ -206,7 +207,7 @@ export async function GET(req: NextRequest) { logger.error('Failed to verify certificate', { error }) return NextResponse.json({ error: 'Failed to verify certificate' }, { status: 500 }) } -} +}) /** Generates a human-readable certificate number, e.g. SIM-2026-A3K9XZ2P */ function generateCertificateNumber(): string { diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 8578b25e181..600cf7ace38 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -3,12 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { createAnonymousGetSessionResponse, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { isAuthDisabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const url = new URL(request.url) const path = url.pathname.replace('/api/auth/', '') @@ -18,6 +19,6 @@ export async function GET(request: NextRequest) { } return betterAuthGET(request) -} +}) -export const POST = betterAuthPOST +export const POST = withRouteHandler(betterAuthPOST) diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 67847afbfab..25d0f97490c 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -4,10 +4,11 @@ import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('AuthAccountsAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -54,4 +55,4 @@ export async function GET(request: NextRequest) { logger.error('Failed to fetch accounts', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index e8f05ecfcf1..2a6a459b297 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { auth } from '@/lib/auth' import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -25,7 +26,7 @@ const forgetPasswordSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() @@ -65,4 +66,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 3ef7e89b342..d36ad0f248a 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -5,6 +5,7 @@ import { jwtDecode } from 'jwt-decode' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { OAuthProvider } from '@/lib/oauth' import { parseProvider } from '@/lib/oauth' @@ -19,7 +20,7 @@ interface GoogleIdToken { /** * Get all OAuth connections for the current user */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -134,4 +135,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching OAuth connections`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index d7fe6864acf..185caf3e8bf 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getCanonicalScopesForProvider, @@ -66,7 +67,7 @@ function toCredentialResponse( /** * Get credentials for a specific provider */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -340,4 +341,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching OAuth credentials`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index a2f0e62b733..7a5372e652e 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' export const dynamic = 'force-dynamic' @@ -22,7 +23,7 @@ const disconnectSchema = z.object({ /** * Disconnect an OAuth provider for the current user */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -144,4 +145,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts index c653d35bf61..f238ccd25f9 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts @@ -3,13 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('MicrosoftFileAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { const { searchParams } = new URL(request.url) @@ -110,4 +111,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts index 23bd2e57e5e..3b6b0733a01 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -11,7 +12,7 @@ const logger = createLogger('MicrosoftFilesAPI') /** * Get Excel files from Microsoft OneDrive */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -131,4 +132,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Excel files from Microsoft OneDrive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 4dc048c334b..3d6004b6577 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredential, getOAuthToken, @@ -46,7 +47,7 @@ const tokenQuerySchema = z.object({ * Supports both session-based authentication (for client-side requests) * and workflow-based authentication (for server-side requests) */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] OAuth token API POST request received`) @@ -204,12 +205,12 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error getting access token`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) /** * Get the access token for a specific credential */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -293,4 +294,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching access token`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts index ee4b6f19585..b1240aacb28 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const logger = createLogger('WealthboxItemAPI') /** * Get a single item (note, contact, task) from Wealthbox */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -170,4 +171,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Wealthbox item`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts index aaa1678cac9..a5b7885b406 100644 --- a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts +++ b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const logger = createLogger('WealthboxItemsAPI') /** * Get items (notes, contacts, tasks) from Wealthbox */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -180,4 +181,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Wealthbox items`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/oauth2/authorize-params/route.ts b/apps/sim/app/api/auth/oauth2/authorize-params/route.ts index 1858f7b14f9..9111ca826e9 100644 --- a/apps/sim/app/api/auth/oauth2/authorize-params/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize-params/route.ts @@ -4,13 +4,14 @@ import { and, eq, gt } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' /** * Returns the original OAuth authorize parameters stored in the verification record * for a given consent code. Used by the consent page to reconstruct the authorize URL * when switching accounts. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -56,4 +57,4 @@ export async function GET(request: NextRequest) { nonce: data.nonce, response_type: 'code', }) -} +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index b58fe329c7d..fcf9e389ee3 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ShopifyCallback') @@ -42,7 +43,7 @@ function validateHmac(searchParams: URLSearchParams, clientSecret: string): bool } } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() try { @@ -164,4 +165,4 @@ export async function GET(request: NextRequest) { logger.error('Error in Shopify OAuth callback:', error) return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`) } -} +}) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index dc01838c4a8..edb28a41f3a 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' @@ -12,7 +13,7 @@ const logger = createLogger('ShopifyStore') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() try { @@ -128,4 +129,4 @@ export async function GET(request: NextRequest) { logger.error('Error storing Shopify token:', error) return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_store_error`) } -} +}) diff --git a/apps/sim/app/api/auth/reset-password/route.ts b/apps/sim/app/api/auth/reset-password/route.ts index 0ec277543c4..637ffe65392 100644 --- a/apps/sim/app/api/auth/reset-password/route.ts +++ b/apps/sim/app/api/auth/reset-password/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { auth } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ const resetPasswordSchema = z.object({ .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() @@ -58,4 +59,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 0607f7c8cfb..d4de1ba207d 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -4,6 +4,7 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ShopifyAuthorize') @@ -18,7 +19,7 @@ const SHOPIFY_SCOPES = [ 'write_merchant_managed_fulfillment_orders', ].join(',') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -213,4 +214,4 @@ export async function GET(request: NextRequest) { logger.error('Error initiating Shopify authorization:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/socket-token/route.ts b/apps/sim/app/api/auth/socket-token/route.ts index 810f149b8bb..4ac7af8bfed 100644 --- a/apps/sim/app/api/auth/socket-token/route.ts +++ b/apps/sim/app/api/auth/socket-token/route.ts @@ -3,10 +3,11 @@ import { headers } from 'next/headers' import { NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { isAuthDisabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SocketTokenAPI') -export async function POST() { +export const POST = withRouteHandler(async () => { if (isAuthDisabled) { return NextResponse.json({ token: 'anonymous-socket-token' }) } @@ -41,4 +42,4 @@ export async function POST() { }) return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts index d4bcfa35db2..18d4104db4c 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -3,10 +3,11 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSOProvidersRoute') -export async function GET() { +export const GET = withRouteHandler(async () => { try { const session = await getSession() @@ -60,4 +61,4 @@ export async function GET() { logger.error('Failed to fetch SSO providers', { error }) return NextResponse.json({ error: 'Failed to fetch SSO providers' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 94c57c93478..fdfae67fb8f 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -9,6 +9,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSORegisterRoute') @@ -70,7 +71,7 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [ }), ]) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { if (!env.SSO_ENABLED) { return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 }) @@ -411,4 +412,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index d5e23abf03a..efa33bba34c 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -3,12 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TrelloAuthorize') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -38,4 +39,4 @@ export async function GET(request: NextRequest) { logger.error('Error initiating Trello authorization:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 2aa76dc8ad6..84c89edadef 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -1,9 +1,10 @@ import { type NextRequest, NextResponse } from 'next/server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() return new NextResponse( @@ -127,4 +128,4 @@ export async function GET(request: NextRequest) { }, } ) -} +}) diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 47e59766a4b..e5c29693058 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' @@ -12,7 +13,7 @@ const logger = createLogger('TrelloStore') export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -115,4 +116,4 @@ export async function POST(request: NextRequest) { logger.error('Error storing Trello token:', error) return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts index 7dfeafb2efe..fd07770676e 100644 --- a/apps/sim/app/api/billing/credits/route.ts +++ b/apps/sim/app/api/billing/credits/route.ts @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getCreditBalance } from '@/lib/billing/credits/balance' import { purchaseCredits } from '@/lib/billing/credits/purchase' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CreditsAPI') @@ -13,7 +14,7 @@ const PurchaseSchema = z.object({ requestId: z.string().uuid(), }) -export async function GET() { +export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -29,9 +30,9 @@ export async function GET() { logger.error('Failed to get credit balance', { error, userId: session.user.id }) return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 }) } -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -74,4 +75,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to purchase credits', { error, userId: session.user.id }) return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 7c42728f729..fd9ad19e82f 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -8,10 +8,11 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('BillingPortal') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() try { @@ -83,4 +84,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to create billing portal session', { error }) return NextResponse.json({ error: 'Failed to create billing portal session' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index 3fbae3c1df1..e4a8c0aaf8c 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -9,13 +9,14 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getPlanTierCredits } from '@/lib/billing/plan-helpers' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UnifiedBillingAPI') /** * Unified Billing Endpoint */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() try { @@ -173,4 +174,4 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index 4bb7dbb366c..0d168115e64 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -17,6 +17,7 @@ import { hasUsableSubscriptionStatus, } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('SwitchPlan') @@ -37,7 +38,7 @@ const switchPlanSchema = z.object({ * targetPlanName: string -- e.g. 'pro_6000', 'team_25000' * interval?: 'month' | 'year' -- if omitted, keeps the current interval */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() try { @@ -192,4 +193,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 65899d55572..15b8611588c 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -7,6 +7,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { checkInternalApiKey } from '@/lib/copilot/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('BillingUpdateCostAPI') @@ -25,7 +26,7 @@ const UpdateCostSchema = z.object({ * POST /api/billing/update-cost * Update user cost with a pre-calculated cost value (internal API key auth required) */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() @@ -158,4 +159,4 @@ export async function POST(req: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 2c0ccde08ee..f4a2ac34bcd 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -11,6 +11,7 @@ import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' import { getStorageMethod } from '@/lib/core/storage' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { setChatAuthCookie } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -207,164 +208,172 @@ const otpVerifySchema = z.object({ otp: z.string().length(6, 'OTP must be 6 digits'), }) -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ identifier: string }> } -) { - const { identifier } = await params - const requestId = generateRequestId() - - try { - const body = await request.json() - const { email } = otpRequestSchema.parse(body) - - const deploymentResult = await db - .select({ - id: chat.id, - authType: chat.authType, - allowedEmails: chat.allowedEmails, - title: chat.title, - }) - .from(chat) - .where(and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))) - .limit(1) +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() + + try { + const body = await request.json() + const { email } = otpRequestSchema.parse(body) + + const deploymentResult = await db + .select({ + id: chat.id, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + title: chat.title, + }) + .from(chat) + .where( + and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt)) + ) + .limit(1) - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) - } + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } - const deployment = deploymentResult[0] + const deployment = deploymentResult[0] - if (deployment.authType !== 'email') { - return addCorsHeaders( - createErrorResponse('This chat does not use email authentication', 400), - request - ) - } + if (deployment.authType !== 'email') { + return addCorsHeaders( + createErrorResponse('This chat does not use email authentication', 400), + request + ) + } - const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) - ? deployment.allowedEmails - : [] + const allowedEmails: string[] = Array.isArray(deployment.allowedEmails) + ? deployment.allowedEmails + : [] - if (!isEmailAllowed(email, allowedEmails)) { - return addCorsHeaders(createErrorResponse('Email not authorized for this chat', 403), request) - } + if (!isEmailAllowed(email, allowedEmails)) { + return addCorsHeaders( + createErrorResponse('Email not authorized for this chat', 403), + request + ) + } - const otp = generateOTP() - await storeOTP(email, deployment.id, otp) + const otp = generateOTP() + await storeOTP(email, deployment.id, otp) - const emailHtml = await renderOTPEmail( - otp, - email, - 'email-verification', - deployment.title || 'Chat' - ) + const emailHtml = await renderOTPEmail( + otp, + email, + 'email-verification', + deployment.title || 'Chat' + ) - const emailResult = await sendEmail({ - to: email, - subject: `Verification code for ${deployment.title || 'Chat'}`, - html: emailHtml, - }) + const emailResult = await sendEmail({ + to: email, + subject: `Verification code for ${deployment.title || 'Chat'}`, + html: emailHtml, + }) - if (!emailResult.success) { - logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return addCorsHeaders(createErrorResponse('Failed to send verification email', 500), request) - } + if (!emailResult.success) { + logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) + return addCorsHeaders( + createErrorResponse('Failed to send verification email', 500), + request + ) + } - logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`) - return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request) - } catch (error: any) { - if (error instanceof z.ZodError) { + logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`) + return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request) + } catch (error: any) { + if (error instanceof z.ZodError) { + return addCorsHeaders( + createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), + request + ) + } + logger.error(`[${requestId}] Error processing OTP request:`, error) return addCorsHeaders( - createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), + createErrorResponse(error.message || 'Failed to process request', 500), request ) } - logger.error(`[${requestId}] Error processing OTP request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) } -} - -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ identifier: string }> } -) { - const { identifier } = await params - const requestId = generateRequestId() - - try { - const body = await request.json() - const { email, otp } = otpVerifySchema.parse(body) - - const deploymentResult = await db - .select({ - id: chat.id, - authType: chat.authType, - password: chat.password, - }) - .from(chat) - .where(and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))) - .limit(1) +) + +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() + + try { + const body = await request.json() + const { email, otp } = otpVerifySchema.parse(body) + + const deploymentResult = await db + .select({ + id: chat.id, + authType: chat.authType, + password: chat.password, + }) + .from(chat) + .where( + and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt)) + ) + .limit(1) - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) - } + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } - const deployment = deploymentResult[0] + const deployment = deploymentResult[0] - const storedValue = await getOTP(email, deployment.id) - if (!storedValue) { - return addCorsHeaders( - createErrorResponse('No verification code found, request a new one', 400), - request - ) - } + const storedValue = await getOTP(email, deployment.id) + if (!storedValue) { + return addCorsHeaders( + createErrorResponse('No verification code found, request a new one', 400), + request + ) + } - const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) + const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) - if (attempts >= MAX_OTP_ATTEMPTS) { - await deleteOTP(email, deployment.id) - logger.warn(`[${requestId}] OTP already locked out for ${email}`) - return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), - request - ) - } - - if (storedOTP !== otp) { - const result = await incrementOTPAttempts(email, deployment.id, storedValue) - if (result === 'locked') { - logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) + if (attempts >= MAX_OTP_ATTEMPTS) { + await deleteOTP(email, deployment.id) + logger.warn(`[${requestId}] OTP already locked out for ${email}`) return addCorsHeaders( createErrorResponse('Too many failed attempts. Please request a new code.', 429), request ) } - return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) - } - await deleteOTP(email, deployment.id) + if (storedOTP !== otp) { + const result = await incrementOTPAttempts(email, deployment.id, storedValue) + if (result === 'locked') { + logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`) + return addCorsHeaders( + createErrorResponse('Too many failed attempts. Please request a new code.', 429), + request + ) + } + return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) + } + + await deleteOTP(email, deployment.id) - const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) - return response - } catch (error: any) { - if (error instanceof z.ZodError) { + return response + } catch (error: any) { + if (error instanceof z.ZodError) { + return addCorsHeaders( + createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), + request + ) + } + logger.error(`[${requestId}] Error verifying OTP:`, error) return addCorsHeaders( - createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), + createErrorResponse(error.message || 'Failed to process request', 500), request ) } - logger.error(`[${requestId}] Error verifying OTP:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) } -} +) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 3f6e14a41f7..bfb038cc423 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { ChatFiles } from '@/lib/uploads' @@ -34,76 +35,127 @@ const chatPostBodySchema = z.object({ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ identifier: string }> } -) { - const { identifier } = await params - const requestId = generateRequestId() +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() - try { - let parsedBody try { - const rawBody = await request.json() - const validation = chatPostBodySchema.safeParse(rawBody) - - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - logger.warn(`[${requestId}] Validation error: ${errorMessage}`) - return addCorsHeaders( - createErrorResponse(`Invalid request body: ${errorMessage}`, 400), - request - ) + let parsedBody + try { + const rawBody = await request.json() + const validation = chatPostBodySchema.safeParse(rawBody) + + if (!validation.success) { + const errorMessage = validation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + logger.warn(`[${requestId}] Validation error: ${errorMessage}`) + return addCorsHeaders( + createErrorResponse(`Invalid request body: ${errorMessage}`, 400), + request + ) + } + + parsedBody = validation.data + } catch (_error) { + return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) } - parsedBody = validation.data - } catch (_error) { - return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) - } + const deploymentResult = await db + .select({ + id: chat.id, + workflowId: chat.workflowId, + userId: chat.userId, + isActive: chat.isActive, + authType: chat.authType, + password: chat.password, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) - const deploymentResult = await db - .select({ - id: chat.id, - workflowId: chat.workflowId, - userId: chat.userId, - isActive: chat.isActive, - authType: chat.authType, - password: chat.password, - allowedEmails: chat.allowedEmails, - outputConfigs: chat.outputConfigs, - }) - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) - } + const deployment = deploymentResult[0] - const deployment = deploymentResult[0] + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chat is not active: ${identifier}`) - if (!deployment.isActive) { - logger.warn(`[${requestId}] Chat is not active: ${identifier}`) + const [workflowRecord] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) + .limit(1) - const [workflowRecord] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) - .limit(1) + const workspaceId = workflowRecord?.workspaceId + if (!workspaceId) { + logger.warn( + `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` + ) + return addCorsHeaders( + createErrorResponse('This chat is currently unavailable', 403), + request + ) + } + + const executionId = generateId() + const loggingSession = new LoggingSession( + deployment.workflowId, + executionId, + 'chat', + requestId + ) + + await loggingSession.safeStart({ + userId: deployment.userId, + workspaceId, + variables: {}, + }) + + await loggingSession.safeCompleteWithError({ + error: { + message: 'This chat is currently unavailable. The chat has been disabled.', + stackTrace: undefined, + }, + traceSpans: [], + }) - const workspaceId = workflowRecord?.workspaceId - if (!workspaceId) { - logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`) return addCorsHeaders( createErrorResponse('This chat is currently unavailable', 403), request ) } + const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) + if (!authResult.authorized) { + return addCorsHeaders( + createErrorResponse(authResult.error || 'Authentication required', 401), + request + ) + } + + const { input, password, email, conversationId, files } = parsedBody + + if ((password || email) && !input) { + const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + + setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) + + return response + } + + if (!input && (!files || files.length === 0)) { + return addCorsHeaders(createErrorResponse('No input provided', 400), request) + } + const executionId = generateId() + const loggingSession = new LoggingSession( deployment.workflowId, executionId, @@ -111,163 +163,127 @@ export async function POST( requestId ) - await loggingSession.safeStart({ + const preprocessResult = await preprocessExecution({ + workflowId: deployment.workflowId, userId: deployment.userId, - workspaceId, - variables: {}, - }) - - await loggingSession.safeCompleteWithError({ - error: { - message: 'This chat is currently unavailable. The chat has been disabled.', - stackTrace: undefined, - }, - traceSpans: [], + triggerType: 'chat', + executionId, + requestId, + checkRateLimit: true, + checkDeployment: true, + loggingSession, }) - return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request) - } - - const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) - if (!authResult.authorized) { - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) - } - - const { input, password, email, conversationId, files } = parsedBody - - if ((password || email) && !input) { - const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) - - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) - - return response - } - - if (!input && (!files || files.length === 0)) { - return addCorsHeaders(createErrorResponse('No input provided', 400), request) - } - - const executionId = generateId() - - const loggingSession = new LoggingSession(deployment.workflowId, executionId, 'chat', requestId) - - const preprocessResult = await preprocessExecution({ - workflowId: deployment.workflowId, - userId: deployment.userId, - triggerType: 'chat', - executionId, - requestId, - checkRateLimit: true, - checkDeployment: true, - loggingSession, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - return addCorsHeaders( - createErrorResponse( - preprocessResult.error?.message || 'Failed to process request', - preprocessResult.error?.statusCode || 500 - ), - request - ) - } - - const { actorUserId, workflowRecord } = preprocessResult - const workspaceOwnerId = actorUserId! - const workspaceId = workflowRecord?.workspaceId - if (!workspaceId) { - logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) - return addCorsHeaders( - createErrorResponse('Workflow has no associated workspace', 500), - request - ) - } - - try { - const selectedOutputs: string[] = [] - if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { - for (const config of deployment.outputConfigs) { - const outputId = config.path - ? `${config.blockId}_${config.path}` - : `${config.blockId}_content` - selectedOutputs.push(outputId) - } + if (!preprocessResult.success) { + logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) + return addCorsHeaders( + createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 + ), + request + ) } - const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') - const { SSE_HEADERS } = await import('@/lib/core/utils/sse') + const { actorUserId, workflowRecord } = preprocessResult + const workspaceOwnerId = actorUserId! + const workspaceId = workflowRecord?.workspaceId + if (!workspaceId) { + logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) + return addCorsHeaders( + createErrorResponse('Workflow has no associated workspace', 500), + request + ) + } - const workflowInput: any = { input, conversationId } - if (files && Array.isArray(files) && files.length > 0) { - const executionContext = { - workspaceId, - workflowId: deployment.workflowId, - executionId, + try { + const selectedOutputs: string[] = [] + if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { + for (const config of deployment.outputConfigs) { + const outputId = config.path + ? `${config.blockId}_${config.path}` + : `${config.blockId}_content` + selectedOutputs.push(outputId) + } } - try { - const uploadedFiles = await ChatFiles.processChatFiles( - files, - executionContext, - requestId, - deployment.userId - ) + const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming') + const { SSE_HEADERS } = await import('@/lib/core/utils/sse') - if (uploadedFiles.length > 0) { - workflowInput.files = uploadedFiles - logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`) + const workflowInput: any = { input, conversationId } + if (files && Array.isArray(files) && files.length > 0) { + const executionContext = { + workspaceId, + workflowId: deployment.workflowId, + executionId, } - } catch (fileError: any) { - logger.error(`[${requestId}] Failed to process chat files:`, fileError) - await loggingSession.safeStart({ - userId: workspaceOwnerId, - workspaceId, - variables: {}, - }) - - await loggingSession.safeCompleteWithError({ - error: { - message: `File upload failed: ${fileError.message || 'Unable to process uploaded files'}`, - stackTrace: fileError.stack, - }, - traceSpans: [], - }) - - throw fileError + try { + const uploadedFiles = await ChatFiles.processChatFiles( + files, + executionContext, + requestId, + deployment.userId + ) + + if (uploadedFiles.length > 0) { + workflowInput.files = uploadedFiles + logger.info(`[${requestId}] Successfully processed ${uploadedFiles.length} files`) + } + } catch (fileError: any) { + logger.error(`[${requestId}] Failed to process chat files:`, fileError) + + await loggingSession.safeStart({ + userId: workspaceOwnerId, + workspaceId, + variables: {}, + }) + + await loggingSession.safeCompleteWithError({ + error: { + message: `File upload failed: ${fileError.message || 'Unable to process uploaded files'}`, + stackTrace: fileError.stack, + }, + traceSpans: [], + }) + + throw fileError + } } - } - - const workflowForExecution = { - id: deployment.workflowId, - userId: deployment.userId, - workspaceId, - isDeployed: workflowRecord?.isDeployed ?? false, - variables: (workflowRecord?.variables as Record) ?? undefined, - } - const stream = await createStreamingResponse({ - requestId, - workflow: workflowForExecution, - input: workflowInput, - executingUserId: workspaceOwnerId, - streamConfig: { - selectedOutputs, - isSecureMode: true, - workflowTriggerType: 'chat', - }, - executionId, - }) + const workflowForExecution = { + id: deployment.workflowId, + userId: deployment.userId, + workspaceId, + isDeployed: workflowRecord?.isDeployed ?? false, + variables: (workflowRecord?.variables as Record) ?? undefined, + } - const streamResponse = new NextResponse(stream, { - status: 200, - headers: SSE_HEADERS, - }) - return addCorsHeaders(streamResponse, request) + const stream = await createStreamingResponse({ + requestId, + workflow: workflowForExecution, + input: workflowInput, + executingUserId: workspaceOwnerId, + streamConfig: { + selectedOutputs, + isSecureMode: true, + workflowTriggerType: 'chat', + }, + executionId, + }) + + const streamResponse = new NextResponse(stream, { + status: 200, + headers: SSE_HEADERS, + }) + return addCorsHeaders(streamResponse, request) + } catch (error: any) { + logger.error(`[${requestId}] Error processing chat request:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to process request', 500), + request + ) + } } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) return addCorsHeaders( @@ -275,60 +291,79 @@ export async function POST( request ) } - } catch (error: any) { - logger.error(`[${requestId}] Error processing chat request:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process request', 500), - request - ) } -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ identifier: string }> } -) { - const { identifier } = await params - const requestId = generateRequestId() - - try { - const deploymentResult = await db - .select({ - id: chat.id, - title: chat.title, - description: chat.description, - customizations: chat.customizations, - isActive: chat.isActive, - workflowId: chat.workflowId, - authType: chat.authType, - password: chat.password, - allowedEmails: chat.allowedEmails, - outputConfigs: chat.outputConfigs, - }) - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) +) - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() - const deployment = deploymentResult[0] + try { + const deploymentResult = await db + .select({ + id: chat.id, + title: chat.title, + description: chat.description, + customizations: chat.customizations, + isActive: chat.isActive, + workflowId: chat.workflowId, + authType: chat.authType, + password: chat.password, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + }) + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) - if (!deployment.isActive) { - logger.warn(`[${requestId}] Chat is not active: ${identifier}`) - return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request) - } + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } + + const deployment = deploymentResult[0] + + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chat is not active: ${identifier}`) + return addCorsHeaders( + createErrorResponse('This chat is currently unavailable', 403), + request + ) + } - const cookieName = `chat_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) + const cookieName = `chat_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) + + if ( + deployment.authType !== 'public' && + authCookie && + validateAuthToken(authCookie.value, deployment.id, deployment.password) + ) { + return addCorsHeaders( + createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + outputConfigs: deployment.outputConfigs, + }), + request + ) + } + + const authResult = await validateChatAuth(requestId, deployment, request) + if (!authResult.authorized) { + logger.info( + `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` + ) + return addCorsHeaders( + createErrorResponse(authResult.error || 'Authentication required', 401), + request + ) + } - if ( - deployment.authType !== 'public' && - authCookie && - validateAuthToken(authCookie.value, deployment.id, deployment.password) - ) { return addCorsHeaders( createSuccessResponse({ id: deployment.id, @@ -340,35 +375,12 @@ export async function GET( }), request ) - } - - const authResult = await validateChatAuth(requestId, deployment, request) - if (!authResult.authorized) { - logger.info( - `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` - ) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching chat info:`, error) return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), + createErrorResponse(error.message || 'Failed to fetch chat information', 500), request ) } - - return addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - outputConfigs: deployment.outputConfigs, - }), - request - ) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching chat info:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to fetch chat information', 500), - request - ) } -} +) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index c09688c99d6..bc222e505c3 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performChatUndeploy } from '@/lib/workflows/orchestration' import { deployWorkflow } from '@/lib/workflows/persistence/utils' import { checkChatAccess } from '@/app/api/chat/utils' @@ -50,251 +51,256 @@ const chatUpdateSchema = z.object({ /** * GET endpoint to fetch a specific chat deployment by ID */ -export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const chatId = id +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const chatId = id - try { - const session = await getSession() + try { + const session = await getSession() - if (!session) { - return createErrorResponse('Unauthorized', 401) - } + if (!session) { + return createErrorResponse('Unauthorized', 401) + } - const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id) + const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id) - if (!hasAccess || !chatRecord) { - return createErrorResponse('Chat not found or access denied', 404) - } + if (!hasAccess || !chatRecord) { + return createErrorResponse('Chat not found or access denied', 404) + } - const { password, ...safeData } = chatRecord + const { password, ...safeData } = chatRecord - const baseDomain = getEmailDomain() - const protocol = isDev ? 'http' : 'https' - const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}` + const baseDomain = getEmailDomain() + const protocol = isDev ? 'http' : 'https' + const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}` - const result = { - ...safeData, - chatUrl, - hasPassword: !!password, - } + const result = { + ...safeData, + chatUrl, + hasPassword: !!password, + } - return createSuccessResponse(result) - } catch (error: any) { - logger.error('Error fetching chat deployment:', error) - return createErrorResponse(error.message || 'Failed to fetch chat deployment', 500) + return createSuccessResponse(result) + } catch (error: any) { + logger.error('Error fetching chat deployment:', error) + return createErrorResponse(error.message || 'Failed to fetch chat deployment', 500) + } } -} +) /** * PATCH endpoint to update an existing chat deployment */ -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const chatId = id - - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - const body = await request.json() +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const chatId = id try { - const validatedData = chatUpdateSchema.parse(body) - - const { - hasAccess, - chat: existingChatRecord, - workspaceId: chatWorkspaceId, - } = await checkChatAccess(chatId, session.user.id) + const session = await getSession() - if (!hasAccess || !existingChatRecord) { - return createErrorResponse('Chat not found or access denied', 404) + if (!session) { + return createErrorResponse('Unauthorized', 401) } - const existingChat = [existingChatRecord] - - const { - workflowId, - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - outputConfigs, - } = validatedData - - if (identifier && identifier !== existingChat[0].identifier) { - const existingIdentifier = await db - .select() - .from(chat) - .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) - .limit(1) - - if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) { - return createErrorResponse('Identifier already in use', 400) - } - } + const body = await request.json() - // Redeploy the workflow to ensure latest version is active - const deployResult = await deployWorkflow({ - workflowId: existingChat[0].workflowId, - deployedBy: session.user.id, - }) + try { + const validatedData = chatUpdateSchema.parse(body) - if (!deployResult.success) { - logger.warn( - `Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update` - ) - } else { - logger.info( - `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` - ) - } - - let encryptedPassword + const { + hasAccess, + chat: existingChatRecord, + workspaceId: chatWorkspaceId, + } = await checkChatAccess(chatId, session.user.id) - if (password) { - const { encrypted } = await encryptSecret(password) - encryptedPassword = encrypted - logger.info('Password provided, will be updated') - } else if (authType === 'password' && !password) { - if (existingChat[0].authType !== 'password' || !existingChat[0].password) { - return createErrorResponse('Password is required when using password protection', 400) + if (!hasAccess || !existingChatRecord) { + return createErrorResponse('Chat not found or access denied', 404) } - logger.info('Keeping existing password') - } - const updateData: any = { - updatedAt: new Date(), - } - - if (workflowId) updateData.workflowId = workflowId - if (identifier) updateData.identifier = identifier - if (title) updateData.title = title - if (description !== undefined) updateData.description = description - if (customizations) updateData.customizations = customizations - - if (authType) { - updateData.authType = authType - - if (authType === 'public') { - updateData.password = null - updateData.allowedEmails = [] - } else if (authType === 'password') { - updateData.allowedEmails = [] - } else if (authType === 'email' || authType === 'sso') { - updateData.password = null + const existingChat = [existingChatRecord] + + const { + workflowId, + identifier, + title, + description, + customizations, + authType, + password, + allowedEmails, + outputConfigs, + } = validatedData + + if (identifier && identifier !== existingChat[0].identifier) { + const existingIdentifier = await db + .select() + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1) + + if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) { + return createErrorResponse('Identifier already in use', 400) + } } - } - if (encryptedPassword) { - updateData.password = encryptedPassword - } + // Redeploy the workflow to ensure latest version is active + const deployResult = await deployWorkflow({ + workflowId: existingChat[0].workflowId, + deployedBy: session.user.id, + }) + + if (!deployResult.success) { + logger.warn( + `Failed to redeploy workflow for chat update: ${deployResult.error}, continuing with chat update` + ) + } else { + logger.info( + `Redeployed workflow ${existingChat[0].workflowId} for chat update (v${deployResult.version})` + ) + } - if (allowedEmails) { - updateData.allowedEmails = allowedEmails - } + let encryptedPassword + + if (password) { + const { encrypted } = await encryptSecret(password) + encryptedPassword = encrypted + logger.info('Password provided, will be updated') + } else if (authType === 'password' && !password) { + if (existingChat[0].authType !== 'password' || !existingChat[0].password) { + return createErrorResponse('Password is required when using password protection', 400) + } + logger.info('Keeping existing password') + } - if (outputConfigs) { - updateData.outputConfigs = outputConfigs - } + const updateData: any = { + updatedAt: new Date(), + } - logger.info('Updating chat deployment with values:', { - chatId, - authType: updateData.authType, - hasPassword: updateData.password !== undefined, - emailCount: updateData.allowedEmails?.length, - outputConfigsCount: updateData.outputConfigs ? updateData.outputConfigs.length : undefined, - }) + if (workflowId) updateData.workflowId = workflowId + if (identifier) updateData.identifier = identifier + if (title) updateData.title = title + if (description !== undefined) updateData.description = description + if (customizations) updateData.customizations = customizations + + if (authType) { + updateData.authType = authType + + if (authType === 'public') { + updateData.password = null + updateData.allowedEmails = [] + } else if (authType === 'password') { + updateData.allowedEmails = [] + } else if (authType === 'email' || authType === 'sso') { + updateData.password = null + } + } - await db.update(chat).set(updateData).where(eq(chat.id, chatId)) + if (encryptedPassword) { + updateData.password = encryptedPassword + } - const updatedIdentifier = identifier || existingChat[0].identifier + if (allowedEmails) { + updateData.allowedEmails = allowedEmails + } - const baseDomain = getEmailDomain() - const protocol = isDev ? 'http' : 'https' - const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}` - - logger.info(`Chat "${chatId}" updated successfully`) - - recordAudit({ - workspaceId: chatWorkspaceId || null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CHAT_UPDATED, - resourceType: AuditResourceType.CHAT, - resourceId: chatId, - resourceName: title || existingChatRecord.title, - description: `Updated chat deployment "${title || existingChatRecord.title}"`, - request, - }) + if (outputConfigs) { + updateData.outputConfigs = outputConfigs + } - return createSuccessResponse({ - id: chatId, - chatUrl, - message: 'Chat deployment updated successfully', - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - const errorMessage = validationError.errors[0]?.message || 'Invalid request data' - return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + logger.info('Updating chat deployment with values:', { + chatId, + authType: updateData.authType, + hasPassword: updateData.password !== undefined, + emailCount: updateData.allowedEmails?.length, + outputConfigsCount: updateData.outputConfigs + ? updateData.outputConfigs.length + : undefined, + }) + + await db.update(chat).set(updateData).where(eq(chat.id, chatId)) + + const updatedIdentifier = identifier || existingChat[0].identifier + + const baseDomain = getEmailDomain() + const protocol = isDev ? 'http' : 'https' + const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}` + + logger.info(`Chat "${chatId}" updated successfully`) + + recordAudit({ + workspaceId: chatWorkspaceId || null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CHAT_UPDATED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: title || existingChatRecord.title, + description: `Updated chat deployment "${title || existingChatRecord.title}"`, + request, + }) + + return createSuccessResponse({ + id: chatId, + chatUrl, + message: 'Chat deployment updated successfully', + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid request data' + return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') + } + throw validationError } - throw validationError + } catch (error: any) { + logger.error('Error updating chat deployment:', error) + return createErrorResponse(error.message || 'Failed to update chat deployment', 500) } - } catch (error: any) { - logger.error('Error updating chat deployment:', error) - return createErrorResponse(error.message || 'Failed to update chat deployment', 500) } -} +) /** * DELETE endpoint to remove a chat deployment */ -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const chatId = id - - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } +export const DELETE = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const chatId = id - const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess( - chatId, - session.user.id - ) + try { + const session = await getSession() - if (!hasAccess) { - return createErrorResponse('Chat not found or access denied', 404) - } + if (!session) { + return createErrorResponse('Unauthorized', 401) + } - const result = await performChatUndeploy({ - chatId, - userId: session.user.id, - workspaceId: chatWorkspaceId, - }) + const { hasAccess, workspaceId: chatWorkspaceId } = await checkChatAccess( + chatId, + session.user.id + ) - if (!result.success) { - return createErrorResponse(result.error || 'Failed to delete chat', 500) - } + if (!hasAccess) { + return createErrorResponse('Chat not found or access denied', 404) + } - return createSuccessResponse({ - message: 'Chat deployment deleted successfully', - }) - } catch (error: any) { - logger.error('Error deleting chat deployment:', error) - return createErrorResponse(error.message || 'Failed to delete chat deployment', 500) + const result = await performChatUndeploy({ + chatId, + userId: session.user.id, + workspaceId: chatWorkspaceId, + }) + + if (!result.success) { + return createErrorResponse(result.error || 'Failed to delete chat', 500) + } + + return createSuccessResponse({ + message: 'Chat deployment deleted successfully', + }) + } catch (error: any) { + logger.error('Error deleting chat deployment:', error) + return createErrorResponse(error.message || 'Failed to delete chat deployment', 500) + } } -} +) diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index c9528715d9d..c0171a024b5 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performChatDeploy } from '@/lib/workflows/orchestration' import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -38,7 +39,7 @@ const chatSchema = z.object({ .default([]), }) -export async function GET(_request: NextRequest) { +export const GET = withRouteHandler(async (_request: NextRequest) => { try { const session = await getSession() @@ -57,9 +58,9 @@ export async function GET(_request: NextRequest) { logger.error('Error fetching chat deployments:', error) return createErrorResponse(error.message || 'Failed to fetch chat deployments', 500) } -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -155,4 +156,4 @@ export async function POST(request: NextRequest) { logger.error('Error creating chat deployment:', error) return createErrorResponse(error.message || 'Failed to create chat deployment', 500) } -} +}) diff --git a/apps/sim/app/api/chat/validate/route.ts b/apps/sim/app/api/chat/validate/route.ts index 6d9fe749b36..59dd09df902 100644 --- a/apps/sim/app/api/chat/validate/route.ts +++ b/apps/sim/app/api/chat/validate/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatValidateAPI') @@ -19,7 +20,7 @@ const validateQuerySchema = z.object({ /** * GET endpoint to validate chat identifier availability */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const { searchParams } = new URL(request.url) const identifier = searchParams.get('identifier') @@ -62,4 +63,4 @@ export async function GET(request: NextRequest) { logger.error('Error validating chat identifier:', error) return createErrorResponse(error.message || 'Failed to validate identifier', 500) } -} +}) diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 27971cede75..50478c2a2b6 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -3,12 +3,13 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const GenerateApiKeySchema = z.object({ name: z.string().min(1, 'Name is required').max(255, 'Name is too long'), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -61,4 +62,4 @@ export async function POST(req: NextRequest) { } catch (error) { return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index 02d0d5be2b0..914c80c4cc7 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -2,8 +2,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -50,9 +51,9 @@ export async function GET(request: NextRequest) { } catch (error) { return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 }) } -} +}) -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -88,4 +89,4 @@ export async function DELETE(request: NextRequest) { } catch (error) { return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 77521f3b3ed..74eed97c63b 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import { checkInternalApiKey } from '@/lib/copilot/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') @@ -10,7 +11,7 @@ const ValidateApiKeySchema = z.object({ userId: z.string().min(1, 'userId is required'), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { const auth = checkInternalApiKey(req) if (!auth.success) { @@ -55,4 +56,4 @@ export async function POST(req: NextRequest) { logger.error('Error validating usage limit', { error }) return NextResponse.json({ error: 'Failed to validate usage' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts index 61343d7541b..e01e99307f4 100644 --- a/apps/sim/app/api/copilot/auto-allowed-tools/route.ts +++ b/apps/sim/app/api/copilot/auto-allowed-tools/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotAutoAllowedToolsAPI') @@ -20,7 +21,7 @@ function copilotHeaders(): Record { /** * GET - Fetch user's auto-allowed integration tools */ -export async function GET() { +export const GET = withRouteHandler(async () => { try { const session = await getSession() @@ -46,12 +47,12 @@ export async function GET() { logger.error('Failed to fetch auto-allowed tools', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) /** * POST - Add a tool to the auto-allowed list */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -86,12 +87,12 @@ export async function POST(request: NextRequest) { logger.error('Failed to add auto-allowed tool', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) /** * DELETE - Remove a tool from the auto-allowed list */ -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -126,4 +127,4 @@ export async function DELETE(request: NextRequest) { logger.error('Failed to remove auto-allowed tool', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 33fe68c8d88..593f5f90fca 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -4,10 +4,11 @@ import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat- import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000 -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const { userId: authenticatedUserId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -61,4 +62,4 @@ export async function POST(request: Request) { ) } return NextResponse.json({ aborted }) -} +}) diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index 652f732e676..733a4e7fc9d 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' import { taskPubSub } from '@/lib/copilot/task-events' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('DeleteChatAPI') @@ -14,7 +15,7 @@ const DeleteChatSchema = z.object({ chatId: z.string(), }) -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -53,4 +54,4 @@ export async function DELETE(request: NextRequest) { logger.error('Error deleting chat:', error) return NextResponse.json({ success: false, error: 'Failed to delete chat' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 8d528150218..55fe9623416 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -12,6 +12,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import type { ChatResource, ResourceType } from '@/lib/copilot/resources' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatResourcesAPI') @@ -50,7 +51,7 @@ const ReorderResourcesSchema = z.object({ ), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -111,9 +112,9 @@ export async function POST(req: NextRequest) { logger.error('Error adding chat resource:', error) return createInternalServerErrorResponse('Failed to add resource') } -} +}) -export async function PATCH(req: NextRequest) { +export const PATCH = withRouteHandler(async (req: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -156,9 +157,9 @@ export async function PATCH(req: NextRequest) { logger.error('Error reordering chat resources:', error) return createInternalServerErrorResponse('Failed to reorder resources') } -} +}) -export async function DELETE(req: NextRequest) { +export const DELETE = withRouteHandler(async (req: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -197,4 +198,4 @@ export async function DELETE(req: NextRequest) { logger.error('Error removing chat resource:', error) return createInternalServerErrorResponse('Failed to remove resource') } -} +}) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 21f83737066..8bd611841e6 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -28,6 +28,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { authorizeWorkflowByWorkspacePermission, @@ -112,7 +113,7 @@ const ChatMessageSchema = z.object({ * POST /api/copilot/chat * Send messages to sim agent and handle chat persistence */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() let actualChatId: string | undefined let pendingChatStreamAcquired = false @@ -671,9 +672,9 @@ export async function POST(req: NextRequest) { { status: 500 } ) } -} +}) -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { try { const { searchParams } = new URL(req.url) const workflowId = searchParams.get('workflowId') @@ -801,4 +802,4 @@ export async function GET(req: NextRequest) { logger.error('Error fetching copilot chats', error) return createInternalServerErrorResponse('Failed to fetch chats') } -} +}) diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index b56d9471817..63dc4790f82 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -7,6 +7,7 @@ import { } from '@/lib/copilot/orchestrator/stream/buffer' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const maxDuration = 3600 @@ -18,7 +19,7 @@ function encodeEvent(event: Record): Uint8Array { return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const { userId: authenticatedUserId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -184,4 +185,4 @@ export async function GET(request: NextRequest) { }) return new Response(stream, { headers: SSE_HEADERS }) -} +}) diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts index 574c2241ede..7ad4a1b6a80 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -13,6 +13,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatUpdateAPI') @@ -54,7 +55,7 @@ const UpdateMessagesSchema = z.object({ .optional(), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -129,4 +130,4 @@ export async function POST(req: NextRequest) { logger.error(`[${tracker.requestId}] Error updating chat messages:`, error) return createInternalServerErrorResponse('Failed to update chat messages') } -} +}) diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 7010d84e92b..6aad9b03cb5 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -12,6 +12,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -24,7 +25,7 @@ const CreateWorkflowCopilotChatSchema = z.object({ const DEFAULT_COPILOT_MODEL = 'claude-opus-4-6' -export async function GET(_request: NextRequest) { +export const GET = withRouteHandler(async (_request: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -81,14 +82,14 @@ export async function GET(_request: NextRequest) { logger.error('Error fetching user copilot chats:', error) return createInternalServerErrorResponse('Failed to fetch user chats') } -} +}) /** * POST /api/copilot/chats * Creates an empty workflow-scoped copilot chat (same lifecycle as {@link resolveOrCreateChat}). * Matches mothership's POST /api/mothership/chats pattern so the client always selects a real row id. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -138,4 +139,4 @@ export async function POST(request: NextRequest) { logger.error('Error creating workflow copilot chat:', error) return createInternalServerErrorResponse('Failed to create chat') } -} +}) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index dd73477f5ec..6bc4b245473 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -13,6 +13,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { isUuidV4 } from '@/executor/constants' @@ -26,7 +27,7 @@ const RevertCheckpointSchema = z.object({ * POST /api/copilot/checkpoints/revert * Revert workflow to a specific checkpoint state */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const tracker = createRequestTracker() try { @@ -155,4 +156,4 @@ export async function POST(request: NextRequest) { logger.error(`[${tracker.requestId}] Error reverting to checkpoint:`, error) return createInternalServerErrorResponse('Failed to revert to checkpoint') } -} +}) diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index 58b4cde4bb2..a13807d3c21 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -12,6 +12,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('WorkflowCheckpointsAPI') @@ -27,7 +28,7 @@ const CreateCheckpointSchema = z.object({ * POST /api/copilot/checkpoints * Create a new checkpoint with JSON workflow state */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -117,13 +118,13 @@ export async function POST(req: NextRequest) { logger.error(`[${tracker.requestId}] Failed to create workflow checkpoint:`, error) return createInternalServerErrorResponse('Failed to create checkpoint') } -} +}) /** * GET /api/copilot/checkpoints?chatId=xxx * Retrieve workflow checkpoints for a chat */ -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -174,4 +175,4 @@ export async function GET(req: NextRequest) { logger.error(`[${tracker.requestId}] Failed to fetch workflow checkpoints:`, error) return createInternalServerErrorResponse('Failed to fetch checkpoints') } -} +}) diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index bf154487f05..ec26b4fe71f 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -17,6 +17,7 @@ import { createUnauthorizedResponse, type NotificationStatus, } from '@/lib/copilot/request-helpers' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotConfirmAPI') @@ -93,7 +94,7 @@ async function updateToolCallStatus( * POST /api/copilot/confirm * Update tool call status (Accept/Reject) */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -166,4 +167,4 @@ export async function POST(req: NextRequest) { error instanceof Error ? error.message : 'Internal server error' ) } -} +}) diff --git a/apps/sim/app/api/copilot/credentials/route.ts b/apps/sim/app/api/copilot/credentials/route.ts index 2f764429d74..6776d688afd 100644 --- a/apps/sim/app/api/copilot/credentials/route.ts +++ b/apps/sim/app/api/copilot/credentials/route.ts @@ -1,13 +1,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' import { routeExecution } from '@/lib/copilot/tools/server/router' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' /** * GET /api/copilot/credentials * Returns connected OAuth credentials for the authenticated user. * Used by the copilot store for credential masking. */ -export async function GET(_req: NextRequest) { +export const GET = withRouteHandler(async (_req: NextRequest) => { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -25,4 +26,4 @@ export async function GET(_req: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index 92abaa1c3e9..116dbc433d0 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -11,6 +11,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CopilotFeedbackAPI') @@ -29,7 +30,7 @@ const FeedbackSchema = z.object({ * POST /api/copilot/feedback * Submit feedback for a copilot interaction */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -113,13 +114,13 @@ export async function POST(req: NextRequest) { return createInternalServerErrorResponse('Failed to submit feedback') } -} +}) /** * GET /api/copilot/feedback * Get feedback records for the authenticated user */ -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -161,4 +162,4 @@ export async function GET(req: NextRequest) { logger.error(`[${tracker.requestId}] Error retrieving copilot feedback:`, error) return createInternalServerErrorResponse('Failed to retrieve feedback') } -} +}) diff --git a/apps/sim/app/api/copilot/models/route.ts b/apps/sim/app/api/copilot/models/route.ts index d1773797453..7abf5648ce6 100644 --- a/apps/sim/app/api/copilot/models/route.ts +++ b/apps/sim/app/api/copilot/models/route.ts @@ -4,6 +4,7 @@ import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' import type { AvailableModel } from '@/lib/copilot/types' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotModelsAPI') @@ -23,7 +24,7 @@ function isRawAvailableModel(item: unknown): item is RawAvailableModel { ) } -export async function GET(_req: NextRequest) { +export const GET = withRouteHandler(async (_req: NextRequest) => { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -81,4 +82,4 @@ export async function GET(_req: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/copilot/stats/route.ts b/apps/sim/app/api/copilot/stats/route.ts index 493f6e4ec90..51f1a0bc5d7 100644 --- a/apps/sim/app/api/copilot/stats/route.ts +++ b/apps/sim/app/api/copilot/stats/route.ts @@ -9,6 +9,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const BodySchema = z.object({ messageId: z.string(), @@ -16,7 +17,7 @@ const BodySchema = z.object({ diffAccepted: z.boolean(), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -63,4 +64,4 @@ export async function POST(req: NextRequest) { } catch (error) { return createInternalServerErrorResponse('Failed to forward copilot stats') } -} +}) diff --git a/apps/sim/app/api/copilot/training/examples/route.ts b/apps/sim/app/api/copilot/training/examples/route.ts index 934ce256875..b34f1e7a193 100644 --- a/apps/sim/app/api/copilot/training/examples/route.ts +++ b/apps/sim/app/api/copilot/training/examples/route.ts @@ -6,6 +6,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotTrainingExamplesAPI') @@ -19,7 +20,7 @@ const TrainingExampleSchema = z.object({ metadata: z.record(z.unknown()).optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() @@ -89,4 +90,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to send workflow example', { error: err }) return NextResponse.json({ error: errorMessage }, { status: 502 }) } -} +}) diff --git a/apps/sim/app/api/copilot/training/route.ts b/apps/sim/app/api/copilot/training/route.ts index e6e58f59bb0..7e42f786f9c 100644 --- a/apps/sim/app/api/copilot/training/route.ts +++ b/apps/sim/app/api/copilot/training/route.ts @@ -6,6 +6,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotTrainingAPI') @@ -25,7 +26,7 @@ const TrainingDataSchema = z.object({ operations: z.array(OperationSchema), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() @@ -109,4 +110,4 @@ export async function POST(request: NextRequest) { { status: 502 } ) } -} +}) diff --git a/apps/sim/app/api/creators/[id]/route.ts b/apps/sim/app/api/creators/[id]/route.ts index c3ee2d90b26..d1e6508caf6 100644 --- a/apps/sim/app/api/creators/[id]/route.ts +++ b/apps/sim/app/api/creators/[id]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CreatorProfileByIdAPI') @@ -47,154 +48,161 @@ async function hasPermission(userId: string, profile: any): Promise { } // GET /api/creators/[id] - Get a specific creator profile -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const profile = await db - .select() - .from(templateCreators) - .where(eq(templateCreators.id, id)) - .limit(1) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const profile = await db + .select() + .from(templateCreators) + .where(eq(templateCreators.id, id)) + .limit(1) + + if (profile.length === 0) { + logger.warn(`[${requestId}] Profile not found: ${id}`) + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) + } - if (profile.length === 0) { - logger.warn(`[${requestId}] Profile not found: ${id}`) - return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) + logger.info(`[${requestId}] Retrieved creator profile: ${id}`) + return NextResponse.json({ data: profile[0] }) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching creator profile: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - logger.info(`[${requestId}] Retrieved creator profile: ${id}`) - return NextResponse.json({ data: profile[0] }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching creator profile: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) // PUT /api/creators/[id] - Update a creator profile -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized update attempt for profile: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized update attempt for profile: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const body = await request.json() - const data = UpdateCreatorProfileSchema.parse(body) + const body = await request.json() + const data = UpdateCreatorProfileSchema.parse(body) - // Check if profile exists - const existing = await db - .select() - .from(templateCreators) - .where(eq(templateCreators.id, id)) - .limit(1) + // Check if profile exists + const existing = await db + .select() + .from(templateCreators) + .where(eq(templateCreators.id, id)) + .limit(1) - if (existing.length === 0) { - logger.warn(`[${requestId}] Profile not found for update: ${id}`) - return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) - } + if (existing.length === 0) { + logger.warn(`[${requestId}] Profile not found for update: ${id}`) + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) + } - // Verification changes require super user permission - if (data.verified !== undefined) { - const { verifyEffectiveSuperUser } = await import('@/lib/templates/permissions') - const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) - if (!effectiveSuperUser) { - logger.warn(`[${requestId}] Non-super user attempted to change creator verification: ${id}`) - return NextResponse.json( - { error: 'Only super users can change verification status' }, - { status: 403 } - ) + // Verification changes require super user permission + if (data.verified !== undefined) { + const { verifyEffectiveSuperUser } = await import('@/lib/templates/permissions') + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { + logger.warn( + `[${requestId}] Non-super user attempted to change creator verification: ${id}` + ) + return NextResponse.json( + { error: 'Only super users can change verification status' }, + { status: 403 } + ) + } } - } - // For non-verified updates, check regular permissions - const hasNonVerifiedUpdates = - data.name !== undefined || data.profileImageUrl !== undefined || data.details !== undefined + // For non-verified updates, check regular permissions + const hasNonVerifiedUpdates = + data.name !== undefined || data.profileImageUrl !== undefined || data.details !== undefined - if (hasNonVerifiedUpdates) { - const canEdit = await hasPermission(session.user.id, existing[0]) - if (!canEdit) { - logger.warn(`[${requestId}] User denied permission to update profile: ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + if (hasNonVerifiedUpdates) { + const canEdit = await hasPermission(session.user.id, existing[0]) + if (!canEdit) { + logger.warn(`[${requestId}] User denied permission to update profile: ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } } - } - const updateData: any = { - updatedAt: new Date(), - } + const updateData: any = { + updatedAt: new Date(), + } - if (data.name !== undefined) updateData.name = data.name - if (data.profileImageUrl !== undefined) updateData.profileImageUrl = data.profileImageUrl - if (data.details !== undefined) updateData.details = data.details - if (data.verified !== undefined) updateData.verified = data.verified - - const updated = await db - .update(templateCreators) - .set(updateData) - .where(eq(templateCreators.id, id)) - .returning() - - logger.info(`[${requestId}] Successfully updated creator profile: ${id}`) - - return NextResponse.json({ data: updated[0] }) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid update data for profile: ${id}`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid update data', details: error.errors }, - { status: 400 } - ) - } + if (data.name !== undefined) updateData.name = data.name + if (data.profileImageUrl !== undefined) updateData.profileImageUrl = data.profileImageUrl + if (data.details !== undefined) updateData.details = data.details + if (data.verified !== undefined) updateData.verified = data.verified + + const updated = await db + .update(templateCreators) + .set(updateData) + .where(eq(templateCreators.id, id)) + .returning() + + logger.info(`[${requestId}] Successfully updated creator profile: ${id}`) + + return NextResponse.json({ data: updated[0] }) + } catch (error: any) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid update data for profile: ${id}`, { + errors: error.errors, + }) + return NextResponse.json( + { error: 'Invalid update data', details: error.errors }, + { status: 400 } + ) + } - logger.error(`[${requestId}] Error updating creator profile: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.error(`[${requestId}] Error updating creator profile: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) // DELETE /api/creators/[id] - Delete a creator profile -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized delete attempt for profile: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized delete attempt for profile: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check if profile exists - const existing = await db - .select() - .from(templateCreators) - .where(eq(templateCreators.id, id)) - .limit(1) + // Check if profile exists + const existing = await db + .select() + .from(templateCreators) + .where(eq(templateCreators.id, id)) + .limit(1) - if (existing.length === 0) { - logger.warn(`[${requestId}] Profile not found for delete: ${id}`) - return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) - } + if (existing.length === 0) { + logger.warn(`[${requestId}] Profile not found for delete: ${id}`) + return NextResponse.json({ error: 'Profile not found' }, { status: 404 }) + } - // Check permissions - const canDelete = await hasPermission(session.user.id, existing[0]) - if (!canDelete) { - logger.warn(`[${requestId}] User denied permission to delete profile: ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + // Check permissions + const canDelete = await hasPermission(session.user.id, existing[0]) + if (!canDelete) { + logger.warn(`[${requestId}] User denied permission to delete profile: ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - await db.delete(templateCreators).where(eq(templateCreators.id, id)) + await db.delete(templateCreators).where(eq(templateCreators.id, id)) - logger.info(`[${requestId}] Successfully deleted creator profile: ${id}`) - return NextResponse.json({ success: true }) - } catch (error: any) { - logger.error(`[${requestId}] Error deleting creator profile: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.info(`[${requestId}] Successfully deleted creator profile: ${id}`) + return NextResponse.json({ success: true }) + } catch (error: any) { + logger.error(`[${requestId}] Error deleting creator profile: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/creators/route.ts b/apps/sim/app/api/creators/route.ts index 6dedd133af1..a171865f157 100644 --- a/apps/sim/app/api/creators/route.ts +++ b/apps/sim/app/api/creators/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { CreatorProfileDetails } from '@/app/_types/creator-profile' const logger = createLogger('CreatorProfilesAPI') @@ -28,7 +29,7 @@ const CreateCreatorProfileSchema = z.object({ }) // GET /api/creators - Get creator profiles for current user -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const { searchParams } = new URL(request.url) const userId = searchParams.get('userId') @@ -79,10 +80,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching creator profiles`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) // POST /api/creators - Create a new creator profile -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -186,4 +187,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error creating creator profile`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts index 9a91b86b8e2..d449917f34e 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' const logger = createLogger('CredentialSetInviteResend') @@ -37,135 +38,140 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin return { set, role: membership.role } } -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const session = await getSession() +export const POST = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } + ) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - const { id, invitationId } = await params + const { id, invitationId } = await params - try { - const result = await getCredentialSetWithAccess(id, session.user.id) + try { + const result = await getCredentialSetWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - const [invitation] = await db - .select() - .from(credentialSetInvitation) - .where( - and( - eq(credentialSetInvitation.id, invitationId), - eq(credentialSetInvitation.credentialSetId, id) + const [invitation] = await db + .select() + .from(credentialSetInvitation) + .where( + and( + eq(credentialSetInvitation.id, invitationId), + eq(credentialSetInvitation.credentialSetId, id) + ) ) - ) - .limit(1) + .limit(1) - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Only pending invitations can be resent' }, { status: 400 }) - } + if (invitation.status !== 'pending') { + return NextResponse.json( + { error: 'Only pending invitations can be resent' }, + { status: 400 } + ) + } + + // Update expiration + const newExpiresAt = new Date() + newExpiresAt.setDate(newExpiresAt.getDate() + 7) + + await db + .update(credentialSetInvitation) + .set({ expiresAt: newExpiresAt }) + .where(eq(credentialSetInvitation.id, invitationId)) + + const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}` + + // Send email if email address exists + if (invitation.email) { + try { + const [inviter] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, result.set.organizationId)) + .limit(1) + + const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' + const emailHtml = await renderPollingGroupInvitationEmail({ + inviterName: inviter?.name || 'A team member', + organizationName: org?.name || 'your organization', + pollingGroupName: result.set.name, + provider, + inviteLink: inviteUrl, + }) - // Update expiration - const newExpiresAt = new Date() - newExpiresAt.setDate(newExpiresAt.getDate() + 7) - - await db - .update(credentialSetInvitation) - .set({ expiresAt: newExpiresAt }) - .where(eq(credentialSetInvitation.id, invitationId)) - - const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}` - - // Send email if email address exists - if (invitation.email) { - try { - const [inviter] = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, result.set.organizationId)) - .limit(1) - - const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' - const emailHtml = await renderPollingGroupInvitationEmail({ - inviterName: inviter?.name || 'A team member', - organizationName: org?.name || 'your organization', - pollingGroupName: result.set.name, - provider, - inviteLink: inviteUrl, - }) - - const emailResult = await sendEmail({ - to: invitation.email, - subject: getEmailSubject('polling-group-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.warn('Failed to resend invitation email', { - email: invitation.email, - error: emailResult.message, + const emailResult = await sendEmail({ + to: invitation.email, + subject: getEmailSubject('polling-group-invitation'), + html: emailHtml, + emailType: 'transactional', }) + + if (!emailResult.success) { + logger.warn('Failed to resend invitation email', { + email: invitation.email, + error: emailResult.message, + }) + return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) + } + } catch (emailError) { + logger.error('Error sending invitation email', emailError) return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) } - } catch (emailError) { - logger.error('Error sending invitation email', emailError) - return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) } - } - logger.info('Resent credential set invitation', { - credentialSetId: id, - invitationId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - resourceName: result.set.name, - description: `Resent credential set invitation to ${invitation.email}`, - metadata: { invitationId, targetEmail: invitation.email }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error resending invitation', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + logger.info('Resent credential set invitation', { + credentialSetId: id, + invitationId, + userId: session.user.id, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: id, + resourceName: result.set.name, + description: `Resent credential set invitation to ${invitation.email}`, + metadata: { invitationId, targetEmail: invitation.email }, + request: req, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error resending invitation', error) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index cd5ebb53015..364e2baa4be 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' const logger = createLogger('CredentialSetInvite') @@ -43,232 +44,238 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin return { set, role: membership.role } } -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) + const { id } = await params + const result = await getCredentialSetWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - const invitations = await db - .select() - .from(credentialSetInvitation) - .where(eq(credentialSetInvitation.credentialSetId, id)) + const invitations = await db + .select() + .from(credentialSetInvitation) + .where(eq(credentialSetInvitation.credentialSetId, id)) - return NextResponse.json({ invitations }) -} + return NextResponse.json({ invitations }) + } +) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - const { id } = await params + const { id } = await params - try { - const result = await getCredentialSetWithAccess(id, session.user.id) + try { + const result = await getCredentialSetWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - const body = await req.json() - const { email } = createInviteSchema.parse(body) - - const token = generateId() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) - - const invitation = { - id: generateId(), - credentialSetId: id, - email: email || null, - token, - invitedBy: session.user.id, - status: 'pending' as const, - expiresAt, - createdAt: new Date(), - } + const body = await req.json() + const { email } = createInviteSchema.parse(body) + + const token = generateId() + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + const invitation = { + id: generateId(), + credentialSetId: id, + email: email || null, + token, + invitedBy: session.user.id, + status: 'pending' as const, + expiresAt, + createdAt: new Date(), + } - await db.insert(credentialSetInvitation).values(invitation) - - const inviteUrl = `${getBaseUrl()}/credential-account/${token}` - - // Send email if email address was provided - if (email) { - try { - // Get inviter name - const [inviter] = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - // Get organization name - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, result.set.organizationId)) - .limit(1) - - const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' - const emailHtml = await renderPollingGroupInvitationEmail({ - inviterName: inviter?.name || 'A team member', - organizationName: org?.name || 'your organization', - pollingGroupName: result.set.name, - provider, - inviteLink: inviteUrl, - }) - - const emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('polling-group-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.warn('Failed to send invitation email', { - email, - error: emailResult.message, + await db.insert(credentialSetInvitation).values(invitation) + + const inviteUrl = `${getBaseUrl()}/credential-account/${token}` + + // Send email if email address was provided + if (email) { + try { + // Get inviter name + const [inviter] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + // Get organization name + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, result.set.organizationId)) + .limit(1) + + const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' + const emailHtml = await renderPollingGroupInvitationEmail({ + inviterName: inviter?.name || 'A team member', + organizationName: org?.name || 'your organization', + pollingGroupName: result.set.name, + provider, + inviteLink: inviteUrl, }) - } - } catch (emailError) { - logger.error('Error sending invitation email', emailError) - // Don't fail the invitation creation if email fails - } - } - logger.info('Created credential set invitation', { - credentialSetId: id, - invitationId: invitation.id, - userId: session.user.id, - emailSent: !!email, - }) + const emailResult = await sendEmail({ + to: email, + subject: getEmailSubject('polling-group-invitation'), + html: emailHtml, + emailType: 'transactional', + }) - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`, - metadata: { targetEmail: email || undefined }, - request: req, - }) + if (!emailResult.success) { + logger.warn('Failed to send invitation email', { + email, + error: emailResult.message, + }) + } + } catch (emailError) { + logger.error('Error sending invitation email', emailError) + // Don't fail the invitation creation if email fails + } + } - return NextResponse.json({ - invitation: { - ...invitation, - inviteUrl, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + logger.info('Created credential set invitation', { + credentialSetId: id, + invitationId: invitation.id, + userId: session.user.id, + emailSent: !!email, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.set.name, + description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`, + metadata: { targetEmail: email || undefined }, + request: req, + }) + + return NextResponse.json({ + invitation: { + ...invitation, + inviteUrl, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error creating invitation', error) + return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } - logger.error('Error creating invitation', error) - return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } -} +) -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - const { id } = await params - const { searchParams } = new URL(req.url) - const invitationId = searchParams.get('invitationId') + const { id } = await params + const { searchParams } = new URL(req.url) + const invitationId = searchParams.get('invitationId') - if (!invitationId) { - return NextResponse.json({ error: 'invitationId is required' }, { status: 400 }) - } + if (!invitationId) { + return NextResponse.json({ error: 'invitationId is required' }, { status: 400 }) + } - try { - const result = await getCredentialSetWithAccess(id, session.user.id) + try { + const result = await getCredentialSetWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - const [revokedInvitation] = await db - .update(credentialSetInvitation) - .set({ status: 'cancelled' }) - .where( - and( - eq(credentialSetInvitation.id, invitationId), - eq(credentialSetInvitation.credentialSetId, id) + const [revokedInvitation] = await db + .update(credentialSetInvitation) + .set({ status: 'cancelled' }) + .where( + and( + eq(credentialSetInvitation.id, invitationId), + eq(credentialSetInvitation.credentialSetId, id) + ) ) - ) - .returning({ email: credentialSetInvitation.email }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`, - metadata: { targetEmail: revokedInvitation?.email ?? undefined }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error cancelling invitation', error) - return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + .returning({ email: credentialSetInvitation.email }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.set.name, + description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`, + metadata: { targetEmail: revokedInvitation?.email ?? undefined }, + request: req, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error cancelling invitation', error) + return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index e6ffbaa6262..87fc6daca70 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -7,6 +7,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetMembers') @@ -36,174 +37,180 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin return { set, role: membership.role } } -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - const members = await db - .select({ - id: credentialSetMember.id, - userId: credentialSetMember.userId, - status: credentialSetMember.status, - joinedAt: credentialSetMember.joinedAt, - createdAt: credentialSetMember.createdAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(credentialSetMember) - .leftJoin(user, eq(credentialSetMember.userId, user.id)) - .where(eq(credentialSetMember.credentialSetId, id)) + const { id } = await params + const result = await getCredentialSetWithAccess(id, session.user.id) - // Get credentials for all active members filtered by the polling group's provider - const activeMembers = members.filter((m) => m.status === 'active') - const memberUserIds = activeMembers.map((m) => m.userId) + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - let credentials: { userId: string; providerId: string; accountId: string }[] = [] - if (memberUserIds.length > 0 && result.set.providerId) { - credentials = await db + const members = await db .select({ - userId: account.userId, - providerId: account.providerId, - accountId: account.accountId, - }) - .from(account) - .where( - and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId)) - ) - } - - // Group credentials by userId - const credentialsByUser = credentials.reduce( - (acc, cred) => { - if (!acc[cred.userId]) { - acc[cred.userId] = [] - } - acc[cred.userId].push({ - providerId: cred.providerId, - accountId: cred.accountId, + id: credentialSetMember.id, + userId: credentialSetMember.userId, + status: credentialSetMember.status, + joinedAt: credentialSetMember.joinedAt, + createdAt: credentialSetMember.createdAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, }) - return acc - }, - {} as Record - ) - - // Attach credentials to members - const membersWithCredentials = members.map((m) => ({ - ...m, - credentials: credentialsByUser[m.userId] || [], - })) - - return NextResponse.json({ members: membersWithCredentials }) -} - -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + .from(credentialSetMember) + .leftJoin(user, eq(credentialSetMember.userId, user.id)) + .where(eq(credentialSetMember.credentialSetId, id)) + + // Get credentials for all active members filtered by the polling group's provider + const activeMembers = members.filter((m) => m.status === 'active') + const memberUserIds = activeMembers.map((m) => m.userId) + + let credentials: { userId: string; providerId: string; accountId: string }[] = [] + if (memberUserIds.length > 0 && result.set.providerId) { + credentials = await db + .select({ + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where( + and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId)) + ) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } + // Group credentials by userId + const credentialsByUser = credentials.reduce( + (acc, cred) => { + if (!acc[cred.userId]) { + acc[cred.userId] = [] + } + acc[cred.userId].push({ + providerId: cred.providerId, + accountId: cred.accountId, + }) + return acc + }, + {} as Record ) - } - const { id } = await params - const { searchParams } = new URL(req.url) - const memberId = searchParams.get('memberId') + // Attach credentials to members + const membersWithCredentials = members.map((m) => ({ + ...m, + credentials: credentialsByUser[m.userId] || [], + })) - if (!memberId) { - return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) + return NextResponse.json({ members: membersWithCredentials }) } +) - try { - const result = await getCredentialSetWithAccess(id, session.user.id) +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) } - const [memberToRemove] = await db - .select({ - id: credentialSetMember.id, - credentialSetId: credentialSetMember.credentialSetId, - userId: credentialSetMember.userId, - status: credentialSetMember.status, - email: user.email, - }) - .from(credentialSetMember) - .innerJoin(user, eq(credentialSetMember.userId, user.id)) - .where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id))) - .limit(1) + const { id } = await params + const { searchParams } = new URL(req.url) + const memberId = searchParams.get('memberId') - if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + if (!memberId) { + return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) } - const requestId = generateId().slice(0, 8) + try { + const result = await getCredentialSetWithAccess(id, session.user.id) - // Use transaction to ensure member deletion + webhook sync are atomic - await db.transaction(async (tx) => { - await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx) - logger.info('Synced webhooks after member removed', { - credentialSetId: id, - ...syncResult, + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + const [memberToRemove] = await db + .select({ + id: credentialSetMember.id, + credentialSetId: credentialSetMember.credentialSetId, + userId: credentialSetMember.userId, + status: credentialSetMember.status, + email: user.email, + }) + .from(credentialSetMember) + .innerJoin(user, eq(credentialSetMember.userId, user.id)) + .where( + and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id)) + ) + .limit(1) + + if (!memberToRemove) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + const requestId = generateId().slice(0, 8) + + // Use transaction to ensure member deletion + webhook sync are atomic + await db.transaction(async (tx) => { + await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) + + const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx) + logger.info('Synced webhooks after member removed', { + credentialSetId: id, + ...syncResult, + }) }) - }) - logger.info('Removed member from credential set', { - credentialSetId: id, - memberId, - userId: session.user.id, - }) + logger.info('Removed member from credential set', { + credentialSetId: id, + memberId, + userId: session.user.id, + }) - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Removed member from credential set "${result.set.name}"`, - metadata: { targetEmail: memberToRemove.email ?? undefined }, - request: req, - }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.set.name, + description: `Removed member from credential set "${result.set.name}"`, + metadata: { targetEmail: memberToRemove.email ?? undefined }, + request: req, + }) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error removing member from credential set', error) - return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error removing member from credential set', error) + return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index 51110916e93..c24c8f52160 100644 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CredentialSet') @@ -44,167 +45,177 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin return { set, role: membership.role } } -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } + + const { id } = await params + const result = await getCredentialSetWithAccess(id, session.user.id) - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + return NextResponse.json({ credentialSet: result.set }) } +) - return NextResponse.json({ credentialSet: result.set }) -} +export const PUT = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() -export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + const { id } = await params - const { id } = await params + try { + const result = await getCredentialSetWithAccess(id, session.user.id) - try { - const result = await getCredentialSetWithAccess(id, session.user.id) + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + const body = await req.json() + const updates = updateCredentialSetSchema.parse(body) + + if (updates.name) { + const existingSet = await db + .select({ id: credentialSet.id }) + .from(credentialSet) + .where( + and( + eq(credentialSet.organizationId, result.set.organizationId), + eq(credentialSet.name, updates.name) + ) + ) + .limit(1) - const body = await req.json() - const updates = updateCredentialSetSchema.parse(body) + if (existingSet.length > 0 && existingSet[0].id !== id) { + return NextResponse.json( + { error: 'A credential set with this name already exists' }, + { status: 409 } + ) + } + } - if (updates.name) { - const existingSet = await db - .select({ id: credentialSet.id }) + await db + .update(credentialSet) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(credentialSet.id, id)) + + const [updated] = await db + .select() .from(credentialSet) - .where( - and( - eq(credentialSet.organizationId, result.set.organizationId), - eq(credentialSet.name, updates.name) - ) - ) + .where(eq(credentialSet.id, id)) .limit(1) - if (existingSet.length > 0 && existingSet[0].id !== id) { - return NextResponse.json( - { error: 'A credential set with this name already exists' }, - { status: 409 } - ) - } - } - - await db - .update(credentialSet) - .set({ - ...updates, - updatedAt: new Date(), + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.CREDENTIAL_SET_UPDATED, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: updated?.name ?? result.set.name, + description: `Updated credential set "${updated?.name ?? result.set.name}"`, + request: req, }) - .where(eq(credentialSet.id, id)) - - const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_UPDATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: updated?.name ?? result.set.name, - description: `Updated credential set "${updated?.name ?? result.set.name}"`, - request: req, - }) - return NextResponse.json({ credentialSet: updated }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + return NextResponse.json({ credentialSet: updated }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error updating credential set', error) + return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) } - logger.error('Error updating credential set', error) - return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) } -} +) -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } + // Check plan access (team/enterprise) or env var override + const hasAccess = await hasCredentialSetsAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Credential sets require a Team or Enterprise plan' }, + { status: 403 } + ) + } - const { id } = await params + const { id } = await params - try { - const result = await getCredentialSetWithAccess(id, session.user.id) + try { + const result = await getCredentialSetWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id)) - await db.delete(credentialSet).where(eq(credentialSet.id, id)) - - logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_DELETED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Deleted credential set "${result.set.name}"`, - request: req, - }) + await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id)) + await db.delete(credentialSet).where(eq(credentialSet.id, id)) + + logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.CREDENTIAL_SET_DELETED, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.set.name, + description: `Deleted credential set "${result.set.name}"`, + request: req, + }) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting credential set', error) - return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting credential set', error) + return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts index 0a4df723154..2ad4eb23d11 100644 --- a/apps/sim/app/api/credential-sets/invitations/route.ts +++ b/apps/sim/app/api/credential-sets/invitations/route.ts @@ -4,10 +4,11 @@ import { createLogger } from '@sim/logger' import { and, eq, gt, isNull, or } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CredentialSetInvitations') -export async function GET() { +export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id || !session?.user?.email) { @@ -50,4 +51,4 @@ export async function GET() { logger.error('Error fetching credential set invitations', error) return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index 656d39fdde1..99efef7e182 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -11,89 +11,37 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetInviteToken') -export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) { - const { token } = await params - - const [invitation] = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - organizationId: credentialSet.organizationId, - organizationName: organization.name, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .where(eq(credentialSetInvitation.token, token)) - .limit(1) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) - } - - if (new Date() > invitation.expiresAt) { - await db - .update(credentialSetInvitation) - .set({ status: 'expired' }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) - } +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { + const { token } = await params - return NextResponse.json({ - invitation: { - credentialSetName: invitation.credentialSetName, - organizationName: invitation.organizationName, - providerId: invitation.providerId, - email: invitation.email, - }, - }) -} - -export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) { - const { token } = await params - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - try { - const [invitationData] = await db + const [invitation] = await db .select({ id: credentialSetInvitation.id, credentialSetId: credentialSetInvitation.credentialSetId, email: credentialSetInvitation.email, status: credentialSetInvitation.status, expiresAt: credentialSetInvitation.expiresAt, - invitedBy: credentialSetInvitation.invitedBy, credentialSetName: credentialSet.name, providerId: credentialSet.providerId, + organizationId: credentialSet.organizationId, + organizationName: organization.name, }) .from(credentialSetInvitation) .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) .where(eq(credentialSetInvitation.token, token)) .limit(1) - if (!invitationData) { + if (!invitation) { return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) } - const invitation = invitationData - if (invitation.status !== 'pending') { return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) } @@ -107,49 +55,95 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) } - const existingMember = await db - .select() - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, invitation.credentialSetId), - eq(credentialSetMember.userId, session.user.id) - ) - ) - .limit(1) + return NextResponse.json({ + invitation: { + credentialSetName: invitation.credentialSetName, + organizationName: invitation.organizationName, + providerId: invitation.providerId, + email: invitation.email, + }, + }) + } +) + +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { + const { token } = await params - if (existingMember.length > 0) { - return NextResponse.json( - { error: 'Already a member of this credential set' }, - { status: 409 } - ) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const now = new Date() - const requestId = generateId().slice(0, 8) + try { + const [invitationData] = await db + .select({ + id: credentialSetInvitation.id, + credentialSetId: credentialSetInvitation.credentialSetId, + email: credentialSetInvitation.email, + status: credentialSetInvitation.status, + expiresAt: credentialSetInvitation.expiresAt, + invitedBy: credentialSetInvitation.invitedBy, + credentialSetName: credentialSet.name, + providerId: credentialSet.providerId, + }) + .from(credentialSetInvitation) + .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .where(eq(credentialSetInvitation.token, token)) + .limit(1) - await db.transaction(async (tx) => { - await tx.insert(credentialSetMember).values({ - id: generateId(), - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - status: 'active', - joinedAt: now, - invitedBy: invitation.invitedBy, - createdAt: now, - updatedAt: now, - }) + if (!invitationData) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } - await tx - .update(credentialSetInvitation) - .set({ - status: 'accepted', - acceptedAt: now, - acceptedByUserId: session.user.id, + const invitation = invitationData + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) + } + + if (new Date() > invitation.expiresAt) { + await db + .update(credentialSetInvitation) + .set({ status: 'expired' }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) + } + + const existingMember = await db + .select() + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, invitation.credentialSetId), + eq(credentialSetMember.userId, session.user.id) + ) + ) + .limit(1) + + if (existingMember.length > 0) { + return NextResponse.json( + { error: 'Already a member of this credential set' }, + { status: 409 } + ) + } + + const now = new Date() + const requestId = generateId().slice(0, 8) + + await db.transaction(async (tx) => { + await tx.insert(credentialSetMember).values({ + id: generateId(), + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + status: 'active', + joinedAt: now, + invitedBy: invitation.invitedBy, + createdAt: now, + updatedAt: now, }) - .where(eq(credentialSetInvitation.id, invitation.id)) - if (invitation.email) { await tx .update(credentialSetInvitation) .set({ @@ -157,52 +151,63 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok acceptedAt: now, acceptedByUserId: session.user.id, }) - .where( - and( - eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), - eq(credentialSetInvitation.email, invitation.email), - eq(credentialSetInvitation.status, 'pending') + .where(eq(credentialSetInvitation.id, invitation.id)) + + if (invitation.email) { + await tx + .update(credentialSetInvitation) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByUserId: session.user.id, + }) + .where( + and( + eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), + eq(credentialSetInvitation.email, invitation.email), + eq(credentialSetInvitation.status, 'pending') + ) ) - ) - } + } - const syncResult = await syncAllWebhooksForCredentialSet( - invitation.credentialSetId, - requestId, - tx - ) - logger.info('Synced webhooks after member joined', { - credentialSetId: invitation.credentialSetId, - ...syncResult, + const syncResult = await syncAllWebhooksForCredentialSet( + invitation.credentialSetId, + requestId, + tx + ) + logger.info('Synced webhooks after member joined', { + credentialSetId: invitation.credentialSetId, + ...syncResult, + }) }) - }) - logger.info('Accepted credential set invitation', { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - }) + logger.info('Accepted credential set invitation', { + invitationId: invitation.id, + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + }) - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: invitation.credentialSetId, - resourceName: invitation.credentialSetName, - description: `Accepted credential set invitation`, - metadata: { invitationId: invitation.id }, - request: req, - }) + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED, + resourceType: AuditResourceType.CREDENTIAL_SET, + resourceId: invitation.credentialSetId, + resourceName: invitation.credentialSetName, + description: `Accepted credential set invitation`, + metadata: { invitationId: invitation.id }, + request: req, + }) - return NextResponse.json({ - success: true, - credentialSetId: invitation.credentialSetId, - providerId: invitation.providerId, - }) - } catch (error) { - logger.error('Error accepting invitation', error) - return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 }) + return NextResponse.json({ + success: true, + credentialSetId: invitation.credentialSetId, + providerId: invitation.providerId, + }) + } catch (error) { + logger.error('Error accepting invitation', error) + return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index aef704f7b9c..c83e601dc54 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -6,11 +6,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetMemberships') -export async function GET() { +export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { @@ -40,13 +41,13 @@ export async function GET() { logger.error('Error fetching credential set memberships', error) return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 }) } -} +}) /** * Leave a credential set (self-revocation). * Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up. */ -export async function DELETE(req: NextRequest) { +export const DELETE = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -125,4 +126,4 @@ export async function DELETE(req: NextRequest) { logger.error('Error leaving credential set', error) return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index b5166630af9..8b67fa6b811 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CredentialSets') @@ -18,7 +19,7 @@ const createCredentialSetSchema = z.object({ providerId: z.enum(['google-email', 'outlook']), }) -export async function GET(req: Request) { +export const GET = withRouteHandler(async (req: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -88,9 +89,9 @@ export async function GET(req: Request) { ) return NextResponse.json({ credentialSets: setsWithCounts }) -} +}) -export async function POST(req: Request) { +export const POST = withRouteHandler(async (req: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -190,4 +191,4 @@ export async function POST(req: Request) { logger.error('Error creating credential set', error) return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index c89657fe89f..1c9350e35cc 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialMembersAPI') @@ -40,7 +41,7 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str return membership } -export async function GET(_request: NextRequest, context: RouteContext) { +export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { try { const session = await getSession() if (!session?.user?.id) { @@ -87,14 +88,14 @@ export async function GET(_request: NextRequest, context: RouteContext) { logger.error('Failed to fetch credential members', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) const addMemberSchema = z.object({ userId: z.string().min(1), role: z.enum(['admin', 'member']).default('member'), }) -export async function POST(request: NextRequest, context: RouteContext) { +export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { try { const session = await getSession() if (!session?.user?.id) { @@ -150,9 +151,9 @@ export async function POST(request: NextRequest, context: RouteContext) { logger.error('Failed to add credential member', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function DELETE(request: NextRequest, context: RouteContext) { +export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { try { const session = await getSession() if (!session?.user?.id) { @@ -224,4 +225,4 @@ export async function DELETE(request: NextRequest, context: RouteContext) { logger.error('Failed to remove credential member', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 14f2e73142b..73ad73696eb 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' import { syncPersonalEnvCredentialsForUser, @@ -63,264 +64,265 @@ async function getCredentialResponse(credentialId: string, userId: string) { return row ?? null } -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.member) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const row = await getCredentialResponse(id, session.user.id) - return NextResponse.json({ credential: row }, { status: 200 }) - } catch (error) { - logger.error('Failed to fetch credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { id } = await params - const { id } = await params + try { + const access = await getCredentialActorContext(id, session.user.id) + if (!access.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + if (!access.hasWorkspaceAccess || !access.member) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - try { - const parseResult = updateCredentialSchema.safeParse(await request.json()) - if (!parseResult.success) { - return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + const row = await getCredentialResponse(id, session.user.id) + return NextResponse.json({ credential: row }, { status: 200 }) + } catch (error) { + logger.error('Failed to fetch credential', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + } +) - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const updates: Record = {} + const { id } = await params - if (parseResult.data.description !== undefined) { - updates.description = parseResult.data.description ?? null - } + try { + const parseResult = updateCredentialSchema.safeParse(await request.json()) + if (!parseResult.success) { + return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 }) + } - if ( - parseResult.data.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') - ) { - updates.displayName = parseResult.data.displayName - } + const access = await getCredentialActorContext(id, session.user.id) + if (!access.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) + } - if ( - parseResult.data.serviceAccountJson !== undefined && - access.credential.type === 'service_account' - ) { - let parsed: Record - try { - parsed = JSON.parse(parseResult.data.serviceAccountJson) - } catch { - return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) + const updates: Record = {} + + if (parseResult.data.description !== undefined) { + updates.description = parseResult.data.description ?? null } + if ( - parsed.type !== 'service_account' || - typeof parsed.client_email !== 'string' || - typeof parsed.private_key !== 'string' || - typeof parsed.project_id !== 'string' + parseResult.data.displayName !== undefined && + (access.credential.type === 'oauth' || access.credential.type === 'service_account') ) { - return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) + updates.displayName = parseResult.data.displayName } - const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson) - updates.encryptedServiceAccountKey = encrypted - } - if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + if ( + parseResult.data.serviceAccountJson !== undefined && + access.credential.type === 'service_account' + ) { + let parsed: Record + try { + parsed = JSON.parse(parseResult.data.serviceAccountJson) + } catch { + return NextResponse.json({ error: 'Invalid JSON format' }, { status: 400 }) + } + if ( + parsed.type !== 'service_account' || + typeof parsed.client_email !== 'string' || + typeof parsed.private_key !== 'string' || + typeof parsed.project_id !== 'string' + ) { + return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) + } + const { encrypted } = await encryptSecret(parseResult.data.serviceAccountJson) + updates.encryptedServiceAccountKey = encrypted + } + + if (Object.keys(updates).length === 0) { + if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + return NextResponse.json( + { + error: 'No updatable fields provided.', + }, + { status: 400 } + ) + } return NextResponse.json( { - error: 'No updatable fields provided.', + error: + 'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.', }, { status: 400 } ) } - return NextResponse.json( - { - error: - 'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.', - }, - { status: 400 } - ) - } - updates.updatedAt = new Date() - await db.update(credential).set(updates).where(eq(credential.id, id)) + updates.updatedAt = new Date() + await db.update(credential).set(updates).where(eq(credential.id, id)) - const row = await getCredentialResponse(id, session.user.id) - return NextResponse.json({ credential: row }, { status: 200 }) - } catch (error) { - if (error instanceof Error && error.message.includes('unique')) { - return NextResponse.json( - { error: 'A service account credential with this name already exists in the workspace' }, - { status: 409 } - ) + const row = await getCredentialResponse(id, session.user.id) + return NextResponse.json({ credential: row }, { status: 200 }) + } catch (error) { + if (error instanceof Error && error.message.includes('unique')) { + return NextResponse.json( + { error: 'A service account credential with this name already exists in the workspace' }, + { status: 409 } + ) + } + logger.error('Failed to update credential', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - logger.error('Failed to update credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 }) - } + const { id } = await params - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) { - delete current[access.credential.envKey] + try { + const access = await getCredentialActorContext(id, session.user.id) + if (!access.credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 }) } - await db - .insert(environment) - .values({ - id: ownerUserId, + if (access.credential.type === 'env_personal' && access.credential.envKey) { + const ownerUserId = access.credential.envOwnerUserId + if (!ownerUserId) { + return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 }) + } + + const [personalRow] = await db + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, ownerUserId)) + .limit(1) + + const current = ((personalRow?.variables as Record | null) ?? {}) as Record< + string, + string + > + if (access.credential.envKey in current) { + delete current[access.credential.envKey] + } + + await db + .insert(environment) + .values({ + id: ownerUserId, + userId: ownerUserId, + variables: current, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: current, updatedAt: new Date() }, + }) + + await syncPersonalEnvCredentialsForUser({ userId: ownerUserId, - variables: current, - updatedAt: new Date(), + envKeys: Object.keys(current), }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - return NextResponse.json({ success: true }, { status: 200 }) - } + captureServerEvent( + session.user.id, + 'credential_deleted', + { + credential_type: 'env_personal', + provider_id: access.credential.envKey, + workspace_id: access.credential.workspaceId, + }, + { groups: { workspace: access.credential.workspaceId } } + ) - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) { - delete current[access.credential.envKey] + return NextResponse.json({ success: true }, { status: 200 }) } - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), + if (access.credential.type === 'env_workspace' && access.credential.envKey) { + const [workspaceRow] = await db + .select({ + id: workspaceEnvironment.id, + createdAt: workspaceEnvironment.createdAt, + variables: workspaceEnvironment.variables, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) + .limit(1) + + const current = ((workspaceRow?.variables as Record | null) ?? + {}) as Record + if (access.credential.envKey in current) { + delete current[access.credential.envKey] + } + + await db + .insert(workspaceEnvironment) + .values({ + id: workspaceRow?.id || generateId(), + workspaceId: access.credential.workspaceId, + variables: current, + createdAt: workspaceRow?.createdAt || new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + + await syncWorkspaceEnvCredentials({ workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, + envKeys: Object.keys(current), + actingUserId: session.user.id, }) - await syncWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - envKeys: Object.keys(current), - actingUserId: session.user.id, - }) + captureServerEvent( + session.user.id, + 'credential_deleted', + { + credential_type: 'env_workspace', + provider_id: access.credential.envKey, + workspace_id: access.credential.workspaceId, + }, + { groups: { workspace: access.credential.workspaceId } } + ) + + return NextResponse.json({ success: true }, { status: 200 }) + } + + await db.delete(credential).where(eq(credential.id, id)) captureServerEvent( session.user.id, 'credential_deleted', { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, + credential_type: access.credential.type as 'oauth' | 'service_account', + provider_id: access.credential.providerId ?? id, workspace_id: access.credential.workspaceId, }, { groups: { workspace: access.credential.workspaceId } } ) return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + logger.error('Failed to delete credential', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - await db.delete(credential).where(eq(credential.id, id)) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? id, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to delete credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index e00be23b0d0..6fbd7e0bcd1 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -6,6 +6,7 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CredentialDraftAPI') @@ -20,7 +21,7 @@ const createDraftSchema = z.object({ credentialId: z.string().min(1).optional(), }) -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const session = await getSession() if (!session?.user?.id) { @@ -114,4 +115,4 @@ export async function POST(request: Request) { logger.error('Failed to save credential draft', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index a3d72ea90bb..39666550080 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CredentialMembershipsAPI') @@ -12,7 +13,7 @@ const leaveCredentialSchema = z.object({ credentialId: z.string().min(1), }) -export async function GET() { +export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -40,9 +41,9 @@ export async function GET() { logger.error('Failed to list credential memberships', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -117,4 +118,4 @@ export async function DELETE(request: NextRequest) { logger.error('Failed to leave credential', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 7d30b63d7b4..20bad71fb42 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth' @@ -228,7 +229,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa return null } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -344,9 +345,9 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Failed to list credentials`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -642,4 +643,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Failed to create credential`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts index 175654ca69f..760d2573e84 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { JOB_RETENTION_HOURS, JOB_STATUS } from '@/lib/core/async-jobs' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CleanupStaleExecutions') @@ -13,7 +14,7 @@ const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000 const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000) const MAX_INT32 = 2_147_483_647 -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const authError = verifyCronAuth(request, 'Stale execution cleanup') if (authError) { @@ -182,4 +183,4 @@ export async function GET(request: NextRequest) { logger.error('Error in stale execution cleanup job:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts index 8b8f9f71593..a22156b3c94 100644 --- a/apps/sim/app/api/cron/renew-subscriptions/route.ts +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('TeamsSubscriptionRenewal') @@ -33,7 +34,7 @@ async function getCredentialOwner( * Teams subscriptions expire after ~3 days and must be renewed. * Configured in helm/sim/values.yaml under cronjobs.jobs.renewSubscriptions */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const authError = verifyCronAuth(request, 'Teams subscription renewal') if (authError) { @@ -183,4 +184,4 @@ export async function GET(request: NextRequest) { logger.error('Error in Teams subscription renewal job:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/demo-requests/route.ts b/apps/sim/app/api/demo-requests/route.ts index c685f81340d..d2c27dce409 100644 --- a/apps/sim/app/api/demo-requests/route.ts +++ b/apps/sim/app/api/demo-requests/route.ts @@ -5,6 +5,7 @@ import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { @@ -21,7 +22,7 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { refillIntervalMs: 60_000, } -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -100,4 +101,4 @@ ${details} logger.error(`[${requestId}] Error processing demo request`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 6022f2ef653..5905316cbd5 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -16,6 +16,7 @@ import { renderWorkflowNotificationEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const emailTemplates = { // Auth emails @@ -140,7 +141,7 @@ const emailTemplates = { type EmailTemplate = keyof typeof emailTemplates -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const template = searchParams.get('template') as EmailTemplate | null @@ -206,4 +207,4 @@ export async function GET(request: NextRequest) { return new NextResponse(html, { headers: { 'Content-Type': 'text/html' }, }) -} +}) diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 229ba26382f..adaccdd7a63 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment' import type { EnvironmentVariable } from '@/lib/environment/api' @@ -18,7 +19,7 @@ const EnvVarSchema = z.object({ variables: z.record(z.string()), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -89,9 +90,9 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error updating environment variables`, error) return NextResponse.json({ error: 'Failed to update environment variables' }, { status: 500 }) } -} +}) -export async function GET(request: Request) { +export const GET = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { @@ -131,4 +132,4 @@ export async function GET(request: Request) { logger.error(`[${requestId}] Environment fetch error`, error) return NextResponse.json({ error: error.message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 64c24bf0cae..61628634573 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' import { deleteFile, hasCloudStorage } from '@/lib/uploads/core/storage-service' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' @@ -23,7 +24,7 @@ const logger = createLogger('FilesDeleteAPI') /** * Main API route handler for file deletion */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -91,7 +92,7 @@ export async function POST(request: NextRequest) { logger.error('Error parsing request:', error) return createErrorResponse(error instanceof Error ? error : new Error('Invalid request')) } -} +}) /** * Extract storage key from file path @@ -107,6 +108,6 @@ function extractStorageKeyFromPath(filePath: string): string { /** * Handle CORS preflight requests */ -export async function OPTIONS() { +export const OPTIONS = withRouteHandler(async () => { return createOptionsResponse() -} +}) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts index 45f9ebb2439..6463260045b 100644 --- a/apps/sim/app/api/files/download/route.ts +++ b/apps/sim/app/api/files/download/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' import { verifyFileAccess } from '@/app/api/files/authorization' @@ -10,7 +11,7 @@ const logger = createLogger('FileDownload') export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -83,4 +84,4 @@ export async function POST(request: NextRequest) { 500 ) } -} +}) diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts index 02ba826fc90..ac087025083 100644 --- a/apps/sim/app/api/files/multipart/route.ts +++ b/apps/sim/app/api/files/multipart/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getStorageConfig, getStorageProvider, @@ -24,7 +25,7 @@ interface GetPartUrlsRequest { context?: StorageContext } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -273,4 +274,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts index 4b1882f8639..74d70ca2e83 100644 --- a/apps/sim/app/api/files/parse/route.ts +++ b/apps/sim/app/api/files/parse/route.ts @@ -29,6 +29,7 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyFileAccess } from '@/app/api/files/authorization' import type { UserFile } from '@/executor/types' import '@/lib/uploads/core/setup.server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -62,7 +63,7 @@ interface ParseResult { /** * Main API route handler */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const startTime = Date.now() try { @@ -189,7 +190,7 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) /** * Parse a single file and return its content diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts index f2aa4aa320a..ba96146b85c 100644 --- a/apps/sim/app/api/files/presigned/batch/route.ts +++ b/apps/sim/app/api/files/presigned/batch/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' import { @@ -22,7 +23,7 @@ interface BatchPresignedUrlRequest { files: BatchFileRequest[] } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -184,9 +185,9 @@ export async function POST(request: NextRequest) { error instanceof Error ? error : new Error('Failed to generate batch presigned URLs') ) } -} +}) -export async function OPTIONS() { +export const OPTIONS = withRouteHandler(async () => { return NextResponse.json( {}, { @@ -198,4 +199,4 @@ export async function OPTIONS() { }, } ) -} +}) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 43eb8ada95b..7003aa900ae 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' @@ -36,7 +37,7 @@ class ValidationError extends PresignedUrlError { } } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -183,9 +184,9 @@ export async function POST(request: NextRequest) { error instanceof Error ? error : new Error('Failed to generate presigned URL') ) } -} +}) -export async function OPTIONS() { +export const OPTIONS = withRouteHandler(async () => { return NextResponse.json( {}, { @@ -197,4 +198,4 @@ export async function OPTIONS() { }, } ) -} +}) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index bc14086395a..0d0d99581a1 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { generatePptxFromCode } from '@/lib/execution/pptx-vm' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' @@ -75,66 +76,65 @@ function getWorkspaceIdForCompile(key: string): string | undefined { return parseWorkspaceFileKey(key) ?? undefined } -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ path: string[] }> } -) { - try { - const { path } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { + try { + const { path } = await params - if (!path || path.length === 0) { - throw new FileNotFoundError('No file path provided') - } + if (!path || path.length === 0) { + throw new FileNotFoundError('No file path provided') + } - logger.info('File serve request:', { path }) + logger.info('File serve request:', { path }) - const fullPath = path.join('/') - const isS3Path = path[0] === 's3' - const isBlobPath = path[0] === 'blob' - const isCloudPath = isS3Path || isBlobPath - const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath + const fullPath = path.join('/') + const isS3Path = path[0] === 's3' + const isBlobPath = path[0] === 'blob' + const isCloudPath = isS3Path || isBlobPath + const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath - const isPublicByKeyPrefix = - cloudKey.startsWith('profile-pictures/') || cloudKey.startsWith('og-images/') + const isPublicByKeyPrefix = + cloudKey.startsWith('profile-pictures/') || cloudKey.startsWith('og-images/') - if (isPublicByKeyPrefix) { - const context = inferContextFromKey(cloudKey) - logger.info(`Serving public ${context}:`, { cloudKey }) - if (isUsingCloudStorage() || isCloudPath) { - return await handleCloudProxyPublic(cloudKey, context) + if (isPublicByKeyPrefix) { + const context = inferContextFromKey(cloudKey) + logger.info(`Serving public ${context}:`, { cloudKey }) + if (isUsingCloudStorage() || isCloudPath) { + return await handleCloudProxyPublic(cloudKey, context) + } + return await handleLocalFilePublic(fullPath) } - return await handleLocalFilePublic(fullPath) - } - const raw = request.nextUrl.searchParams.get('raw') === '1' + const raw = request.nextUrl.searchParams.get('raw') === '1' - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn('Unauthorized file access attempt', { - path, - error: authResult.error || 'Missing userId', - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized file access attempt', { + path, + error: authResult.error || 'Missing userId', + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = authResult.userId + const userId = authResult.userId - if (isUsingCloudStorage()) { - return await handleCloudProxy(cloudKey, userId, raw) - } + if (isUsingCloudStorage()) { + return await handleCloudProxy(cloudKey, userId, raw) + } - return await handleLocalFile(cloudKey, userId, raw) - } catch (error) { - logger.error('Error serving file:', error) + return await handleLocalFile(cloudKey, userId, raw) + } catch (error) { + logger.error('Error serving file:', error) - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } - return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + } } -} +) async function handleLocalFile( filename: string, diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index b6791a3841b..097b6b9f80d 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { isImageFileType } from '@/lib/uploads/utils/file-utils' @@ -40,7 +41,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FilesUploadAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -364,8 +365,8 @@ export async function POST(request: NextRequest) { logger.error('Error in file upload:', error) return createErrorResponse(error instanceof Error ? error : new Error('File upload failed')) } -} +}) -export async function OPTIONS() { +export const OPTIONS = withRouteHandler(async () => { return createOptionsResponse() -} +}) diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index be6cea9d429..f6ea045ec67 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -22,190 +23,192 @@ const DuplicateRequestSchema = z.object({ }) // POST /api/folders/[id]/duplicate - Duplicate a folder with all its child folders and workflows -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: sourceFolderId } = await 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 body = await req.json() - const { - name, - workspaceId, - parentId, - color, - newId: clientNewId, - } = DuplicateRequestSchema.parse(body) +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: sourceFolderId } = await 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 }) + } - logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) + try { + const body = await req.json() + const { + name, + workspaceId, + parentId, + color, + newId: clientNewId, + } = DuplicateRequestSchema.parse(body) + + logger.info(`[${requestId}] Duplicating folder ${sourceFolderId} for user ${session.user.id}`) + + const sourceFolder = await db + .select() + .from(workflowFolder) + .where(eq(workflowFolder.id, sourceFolderId)) + .then((rows) => rows[0]) + + if (!sourceFolder) { + throw new Error('Source folder not found') + } - const sourceFolder = await db - .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, sourceFolderId)) - .then((rows) => rows[0]) + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + sourceFolder.workspaceId + ) - if (!sourceFolder) { - throw new Error('Source folder not found') - } + if (!userPermission || userPermission === 'read') { + throw new Error('Source folder not found or access denied') + } - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - sourceFolder.workspaceId - ) + const targetWorkspaceId = workspaceId || sourceFolder.workspaceId + + const { newFolderId, folderMapping } = await db.transaction(async (tx) => { + const newFolderId = clientNewId || generateId() + const now = new Date() + const targetParentId = parentId ?? sourceFolder.parentId + + 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 + + await tx.insert(workflowFolder).values({ + id: newFolderId, + userId: session.user.id, + workspaceId: targetWorkspaceId, + name, + color: color || sourceFolder.color, + parentId: targetParentId, + sortOrder, + isExpanded: false, + createdAt: now, + updatedAt: now, + }) - if (!userPermission || userPermission === 'read') { - throw new Error('Source folder not found or access denied') - } + const folderMapping = new Map([[sourceFolderId, newFolderId]]) + await duplicateFolderStructure( + tx, + sourceFolderId, + newFolderId, + sourceFolder.workspaceId, + targetWorkspaceId, + session.user.id, + now, + folderMapping + ) - const targetWorkspaceId = workspaceId || sourceFolder.workspaceId - - const { newFolderId, folderMapping } = await db.transaction(async (tx) => { - const newFolderId = clientNewId || generateId() - const now = new Date() - const targetParentId = parentId ?? sourceFolder.parentId - - 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 - - await tx.insert(workflowFolder).values({ - id: newFolderId, - userId: session.user.id, - workspaceId: targetWorkspaceId, - name, - color: color || sourceFolder.color, - parentId: targetParentId, - sortOrder, - isExpanded: false, - createdAt: now, - updatedAt: now, + return { newFolderId, folderMapping } }) - const folderMapping = new Map([[sourceFolderId, newFolderId]]) - await duplicateFolderStructure( - tx, - sourceFolderId, - newFolderId, + const workflowStats = await duplicateWorkflowsInFolderTree( sourceFolder.workspaceId, targetWorkspaceId, + folderMapping, session.user.id, - now, - folderMapping + requestId ) - return { newFolderId, folderMapping } - }) - - const workflowStats = await duplicateWorkflowsInFolderTree( - sourceFolder.workspaceId, - targetWorkspaceId, - folderMapping, - session.user.id, - requestId - ) - - 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, - workflowsFailed: workflowStats.failed, - } - ) - - 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 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, + workflowsFailed: workflowStats.failed, + } + ) - return NextResponse.json( - { - id: newFolderId, - name, - color: color || sourceFolder.color, + recordAudit({ workspaceId: targetWorkspaceId, - parentId: parentId || sourceFolder.parentId, - foldersCount: folderMapping.size, - workflowsCount: workflowStats.succeeded, - }, - { status: 201 } - ) - } catch (error) { - if (error instanceof Error) { - 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 }) + 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, + }) + + return NextResponse.json( + { + id: newFolderId, + name, + color: color || sourceFolder.color, + workspaceId: targetWorkspaceId, + parentId: parentId || sourceFolder.parentId, + foldersCount: folderMapping.size, + workflowsCount: workflowStats.succeeded, + }, + { status: 201 } + ) + } catch (error) { + if (error instanceof Error) { + 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 === 'Source folder not found or access denied') { - logger.warn( - `[${requestId}] User ${session.user.id} denied access to source folder ${sourceFolderId}` + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { 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 }) } - - 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 duplicateFolderStructure( tx: any, diff --git a/apps/sim/app/api/folders/[id]/restore/route.ts b/apps/sim/app/api/folders/[id]/restore/route.ts index 7aa6a9189c9..5717c0be22a 100644 --- a/apps/sim/app/api/folders/[id]/restore/route.ts +++ b/apps/sim/app/api/folders/[id]/restore/route.ts @@ -1,58 +1,61 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/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') -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: folderId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json().catch(() => ({})) - const workspaceId = body.workspaceId as string | undefined - - if (!workspaceId) { - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: folderId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const workspaceId = body.workspaceId as string | undefined + + if (!workspaceId) { + return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) + } + + 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 ${folderId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) } - - 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 ${folderId}`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index a4c4390b360..e7966299977 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteFolder } from '@/lib/workflows/orchestration' import { checkForCircularReference } from '@/lib/workflows/utils' @@ -21,155 +22,156 @@ const updateFolderSchema = z.object({ }) // PUT - Update a folder -export async function PUT(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 - const body = await request.json() +export const PUT = 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 validationResult = updateFolderSchema.safeParse(body) - if (!validationResult.success) { - logger.error('Folder update validation failed:', { - errors: validationResult.error.errors, - }) - const errorMessages = validationResult.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 }) - } + const { id } = await params + const body = await request.json() + + const validationResult = updateFolderSchema.safeParse(body) + if (!validationResult.success) { + logger.error('Folder update validation failed:', { + errors: validationResult.error.errors, + }) + const errorMessages = validationResult.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + return NextResponse.json({ error: `Validation failed: ${errorMessages}` }, { status: 400 }) + } - const { name, color, isExpanded, parentId, sortOrder } = validationResult.data + const { name, color, isExpanded, parentId, sortOrder } = validationResult.data - // Verify the folder exists - const existingFolder = await db - .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) - .then((rows) => rows[0]) + // 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 }) - } + 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 } + // Check if user has write permissions for the workspace + const workspacePermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + existingFolder.workspaceId ) - } - // Prevent setting a folder as its own parent or creating circular references - if (parentId && parentId === id) { - return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) - } - - // Check for circular references if parentId is provided - if (parentId) { - const wouldCreateCycle = await checkForCircularReference(id, parentId) - if (wouldCreateCycle) { + if (!workspacePermission || workspacePermission === 'read') { return NextResponse.json( - { error: 'Cannot create circular folder reference' }, - { status: 400 } + { error: 'Write access required to update folders' }, + { status: 403 } ) } - } - const updates: Record = { updatedAt: new Date() } - if (name !== undefined) updates.name = name.trim() - if (color !== undefined) updates.color = color - if (isExpanded !== undefined) updates.isExpanded = isExpanded - if (parentId !== undefined) updates.parentId = parentId || null - if (sortOrder !== undefined) updates.sortOrder = sortOrder - - const [updatedFolder] = await db - .update(workflowFolder) - .set(updates) - .where(eq(workflowFolder.id, id)) - .returning() - - logger.info('Updated folder:', { id, updates }) - - return NextResponse.json({ folder: updatedFolder }) - } catch (error) { - logger.error('Error updating folder:', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + // Prevent setting a folder as its own parent or creating circular references + if (parentId && parentId === id) { + return NextResponse.json({ error: 'Folder cannot be its own parent' }, { status: 400 }) + } + + // Check for circular references if parentId is provided + if (parentId) { + const wouldCreateCycle = await checkForCircularReference(id, parentId) + if (wouldCreateCycle) { + return NextResponse.json( + { error: 'Cannot create circular folder reference' }, + { status: 400 } + ) + } + } + + const updates: Record = { updatedAt: new Date() } + if (name !== undefined) updates.name = name.trim() + if (color !== undefined) updates.color = color + if (isExpanded !== undefined) updates.isExpanded = isExpanded + if (parentId !== undefined) updates.parentId = parentId || null + if (sortOrder !== undefined) updates.sortOrder = sortOrder + + const [updatedFolder] = await db + .update(workflowFolder) + .set(updates) + .where(eq(workflowFolder.id, id)) + .returning() + + logger.info('Updated folder:', { id, updates }) + + return NextResponse.json({ folder: updatedFolder }) + } catch (error) { + logger.error('Error updating folder:', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) // DELETE - Delete a folder and all its contents -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +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 + const { id } = await params - // Verify the folder exists - const existingFolder = await db - .select() - .from(workflowFolder) - .where(eq(workflowFolder.id, id)) - .then((rows) => rows[0]) + // 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 }) - } + if (!existingFolder) { + return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) + } + + const workspacePermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + existingFolder.workspaceId + ) + + if (workspacePermission !== 'admin') { + return NextResponse.json( + { error: 'Admin access required to delete folders' }, + { status: 403 } + ) + } + + const result = await performDeleteFolder({ + folderId: id, + workspaceId: existingFolder.workspaceId, + userId: session.user.id, + folderName: existingFolder.name, + }) - const workspacePermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - existingFolder.workspaceId - ) + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) + } - if (workspacePermission !== 'admin') { - return NextResponse.json( - { error: 'Admin access required to delete folders' }, - { status: 403 } + captureServerEvent( + session.user.id, + 'folder_deleted', + { workspace_id: existingFolder.workspaceId }, + { groups: { workspace: existingFolder.workspaceId } } ) - } - 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 }) + return NextResponse.json({ + success: true, + deletedItems: result.deletedItems, + }) + } catch (error) { + logger.error('Error deleting folder:', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - captureServerEvent( - session.user.id, - 'folder_deleted', - { workspace_id: existingFolder.workspaceId }, - { groups: { workspace: existingFolder.workspaceId } } - ) - - return NextResponse.json({ - success: true, - deletedItems: result.deletedItems, - }) - } catch (error) { - logger.error('Error deleting folder:', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/folders/reorder/route.ts b/apps/sim/app/api/folders/reorder/route.ts index 653d8301658..1cc59aa77f9 100644 --- a/apps/sim/app/api/folders/reorder/route.ts +++ b/apps/sim/app/api/folders/reorder/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' 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') @@ -21,7 +22,7 @@ const ReorderSchema = z.object({ ), }) -export async function PUT(req: NextRequest) { +export const PUT = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const session = await getSession() @@ -88,4 +89,4 @@ export async function PUT(req: NextRequest) { logger.error(`[${requestId}] Error reordering folders`, error) return NextResponse.json({ error: 'Failed to reorder folders' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 98e80f5aa3d..ac151e98fdd 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -22,7 +23,7 @@ const CreateFolderSchema = z.object({ }) // GET - Fetch folders for a workspace -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -64,10 +65,10 @@ export async function GET(request: NextRequest) { logger.error('Error fetching folders:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) // POST - Create a new folder -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -185,4 +186,4 @@ export async function POST(request: NextRequest) { logger.error('Error creating folder:', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index f69ab9e1886..c847f14329b 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' @@ -50,75 +51,124 @@ async function getWorkflowInputSchema(workflowId: string): Promise { } } -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ identifier: string }> } -) { - const { identifier } = await params - const requestId = generateRequestId() +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() - try { - let parsedBody try { - const rawBody = await request.json() - const validation = formPostBodySchema.safeParse(rawBody) - - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - logger.warn(`[${requestId}] Validation error: ${errorMessage}`) - return addCorsHeaders( - createErrorResponse(`Invalid request body: ${errorMessage}`, 400), - request - ) + let parsedBody + try { + const rawBody = await request.json() + const validation = formPostBodySchema.safeParse(rawBody) + + if (!validation.success) { + const errorMessage = validation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + logger.warn(`[${requestId}] Validation error: ${errorMessage}`) + return addCorsHeaders( + createErrorResponse(`Invalid request body: ${errorMessage}`, 400), + request + ) + } + + parsedBody = validation.data + } catch (_error) { + return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) } - parsedBody = validation.data - } catch (_error) { - return addCorsHeaders(createErrorResponse('Invalid request body', 400), request) - } + const deploymentResult = await db + .select({ + id: form.id, + workflowId: form.workflowId, + userId: form.userId, + isActive: form.isActive, + authType: form.authType, + password: form.password, + allowedEmails: form.allowedEmails, + customizations: form.customizations, + }) + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1) - const deploymentResult = await db - .select({ - id: form.id, - workflowId: form.workflowId, - userId: form.userId, - isActive: form.isActive, - authType: form.authType, - password: form.password, - allowedEmails: form.allowedEmails, - customizations: form.customizations, - }) - .from(form) - .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) - .limit(1) + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Form not found', 404), request) + } - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) - } + const deployment = deploymentResult[0] + + if (!deployment.isActive) { + logger.warn(`[${requestId}] Form is not active: ${identifier}`) + + const [workflowRecord] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) + .limit(1) + + const workspaceId = workflowRecord?.workspaceId + if (!workspaceId) { + logger.warn( + `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` + ) + return addCorsHeaders( + createErrorResponse('This form is currently unavailable', 403), + request + ) + } - const deployment = deploymentResult[0] + const executionId = generateId() + const loggingSession = new LoggingSession( + deployment.workflowId, + executionId, + 'form', + requestId + ) - if (!deployment.isActive) { - logger.warn(`[${requestId}] Form is not active: ${identifier}`) + await loggingSession.safeStart({ + userId: deployment.userId, + workspaceId, + variables: {}, + }) - const [workflowRecord] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) - .limit(1) + await loggingSession.safeCompleteWithError({ + error: { + message: 'This form is currently unavailable. The form has been disabled.', + stackTrace: undefined, + }, + traceSpans: [], + }) - const workspaceId = workflowRecord?.workspaceId - if (!workspaceId) { - logger.warn(`[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`) return addCorsHeaders( createErrorResponse('This form is currently unavailable', 403), request ) } + const authResult = await validateFormAuth(requestId, deployment, request, parsedBody) + if (!authResult.authorized) { + return addCorsHeaders( + createErrorResponse(authResult.error || 'Authentication required', 401), + request + ) + } + + const { formData, password, email } = parsedBody + + // If only authentication credentials provided (no form data), just return authenticated + if ((password || email) && !formData) { + const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) + setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) + return response + } + + if (!formData || Object.keys(formData).length === 0) { + return addCorsHeaders(createErrorResponse('No form data provided', 400), request) + } + const executionId = generateId() const loggingSession = new LoggingSession( deployment.workflowId, @@ -127,153 +177,119 @@ export async function POST( requestId ) - await loggingSession.safeStart({ + const preprocessResult = await preprocessExecution({ + workflowId: deployment.workflowId, userId: deployment.userId, - workspaceId, - variables: {}, - }) - - await loggingSession.safeCompleteWithError({ - error: { - message: 'This form is currently unavailable. The form has been disabled.', - stackTrace: undefined, - }, - traceSpans: [], + triggerType: 'form', + executionId, + requestId, + checkRateLimit: true, + checkDeployment: true, + loggingSession, }) - return addCorsHeaders(createErrorResponse('This form is currently unavailable', 403), request) - } - - const authResult = await validateFormAuth(requestId, deployment, request, parsedBody) - if (!authResult.authorized) { - return addCorsHeaders( - createErrorResponse(authResult.error || 'Authentication required', 401), - request - ) - } - - const { formData, password, email } = parsedBody - - // If only authentication credentials provided (no form data), just return authenticated - if ((password || email) && !formData) { - const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request) - setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password) - return response - } - - if (!formData || Object.keys(formData).length === 0) { - return addCorsHeaders(createErrorResponse('No form data provided', 400), request) - } - - const executionId = generateId() - const loggingSession = new LoggingSession(deployment.workflowId, executionId, 'form', requestId) - - const preprocessResult = await preprocessExecution({ - workflowId: deployment.workflowId, - userId: deployment.userId, - triggerType: 'form', - executionId, - requestId, - checkRateLimit: true, - checkDeployment: true, - loggingSession, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - return addCorsHeaders( - createErrorResponse( - preprocessResult.error?.message || 'Failed to process request', - preprocessResult.error?.statusCode || 500 - ), - request - ) - } - - const { actorUserId, workflowRecord } = preprocessResult - const workspaceOwnerId = actorUserId! - const workspaceId = workflowRecord?.workspaceId - if (!workspaceId) { - logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) - return addCorsHeaders( - createErrorResponse('Workflow has no associated workspace', 500), - request - ) - } - - try { - const workflowForExecution = { - id: deployment.workflowId, - userId: deployment.userId, - workspaceId, - isDeployed: workflowRecord?.isDeployed ?? false, - variables: (workflowRecord?.variables ?? {}) as Record, + if (!preprocessResult.success) { + logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) + return addCorsHeaders( + createErrorResponse( + preprocessResult.error?.message || 'Failed to process request', + preprocessResult.error?.statusCode || 500 + ), + request + ) } - // Pass form data as the workflow input - const workflowInput = { - input: formData, - ...formData, // Spread form fields at top level for convenience + const { actorUserId, workflowRecord } = preprocessResult + const workspaceOwnerId = actorUserId! + const workspaceId = workflowRecord?.workspaceId + if (!workspaceId) { + logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) + return addCorsHeaders( + createErrorResponse('Workflow has no associated workspace', 500), + request + ) } - // Execute workflow using streaming (for consistency with chat) - const stream = await createStreamingResponse({ - requestId, - workflow: workflowForExecution, - input: workflowInput, - executingUserId: workspaceOwnerId, - streamConfig: { - selectedOutputs: [], - isSecureMode: true, - workflowTriggerType: 'api', // Use 'api' type since form is similar - }, - executionId, - }) + try { + const workflowForExecution = { + id: deployment.workflowId, + userId: deployment.userId, + workspaceId, + isDeployed: workflowRecord?.isDeployed ?? false, + variables: (workflowRecord?.variables ?? {}) as Record, + } - // For forms, we don't stream back - we wait for completion and return success - // Consume the stream to wait for completion - const reader = stream.getReader() - let lastOutput: any = null + // Pass form data as the workflow input + const workflowInput = { + input: formData, + ...formData, // Spread form fields at top level for convenience + } - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - // Parse SSE data if present - const text = new TextDecoder().decode(value) - const lines = text.split('\n') - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)) - if (data.type === 'complete' || data.output) { - lastOutput = data.output || data + // Execute workflow using streaming (for consistency with chat) + const stream = await createStreamingResponse({ + requestId, + workflow: workflowForExecution, + input: workflowInput, + executingUserId: workspaceOwnerId, + streamConfig: { + selectedOutputs: [], + isSecureMode: true, + workflowTriggerType: 'api', // Use 'api' type since form is similar + }, + executionId, + }) + + // For forms, we don't stream back - we wait for completion and return success + // Consume the stream to wait for completion + const reader = stream.getReader() + let lastOutput: any = null + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + // Parse SSE data if present + const text = new TextDecoder().decode(value) + const lines = text.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)) + if (data.type === 'complete' || data.output) { + lastOutput = data.output || data + } + } catch { + // Ignore parse errors } - } catch { - // Ignore parse errors } } } + } finally { + reader.releaseLock() } - } finally { - reader.releaseLock() - } - logger.info(`[${requestId}] Form submission successful for ${identifier}`) + logger.info(`[${requestId}] Form submission successful for ${identifier}`) - // Return success with customizations for thank you screen - const customizations = deployment.customizations as Record | null - return addCorsHeaders( - createSuccessResponse({ - success: true, - executionId, - thankYouTitle: customizations?.thankYouTitle || 'Thank you!', - thankYouMessage: - customizations?.thankYouMessage || 'Your response has been submitted successfully.', - }), - request - ) + // Return success with customizations for thank you screen + const customizations = deployment.customizations as Record | null + return addCorsHeaders( + createSuccessResponse({ + success: true, + executionId, + thankYouTitle: customizations?.thankYouTitle || 'Thank you!', + thankYouMessage: + customizations?.thankYouMessage || 'Your response has been submitted successfully.', + }), + request + ) + } catch (error: any) { + logger.error(`[${requestId}] Error processing form submission:`, error) + return addCorsHeaders( + createErrorResponse(error.message || 'Failed to process form submission', 500), + request + ) + } } catch (error: any) { logger.error(`[${requestId}] Error processing form submission:`, error) return addCorsHeaders( @@ -281,64 +297,98 @@ export async function POST( request ) } - } catch (error: any) { - logger.error(`[${requestId}] Error processing form submission:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to process form submission', 500), - request - ) } -} +) -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ identifier: string }> } -) { - const { identifier } = await params - const requestId = generateRequestId() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() - try { - const deploymentResult = await db - .select({ - id: form.id, - title: form.title, - description: form.description, - customizations: form.customizations, - isActive: form.isActive, - workflowId: form.workflowId, - authType: form.authType, - password: form.password, - allowedEmails: form.allowedEmails, - showBranding: form.showBranding, - }) - .from(form) - .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) - .limit(1) + try { + const deploymentResult = await db + .select({ + id: form.id, + title: form.title, + description: form.description, + customizations: form.customizations, + isActive: form.isActive, + workflowId: form.workflowId, + authType: form.authType, + password: form.password, + allowedEmails: form.allowedEmails, + showBranding: form.showBranding, + }) + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1) - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Form not found', 404), request) - } + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Form not found', 404), request) + } - const deployment = deploymentResult[0] + const deployment = deploymentResult[0] - if (!deployment.isActive) { - logger.warn(`[${requestId}] Form is not active: ${identifier}`) - return addCorsHeaders(createErrorResponse('This form is currently unavailable', 403), request) - } + if (!deployment.isActive) { + logger.warn(`[${requestId}] Form is not active: ${identifier}`) + return addCorsHeaders( + createErrorResponse('This form is currently unavailable', 403), + request + ) + } + + // Get the workflow's input schema + const inputSchema = await getWorkflowInputSchema(deployment.workflowId) - // Get the workflow's input schema - const inputSchema = await getWorkflowInputSchema(deployment.workflowId) + const cookieName = `form_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) - const cookieName = `form_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) + // If authenticated (via cookie), return full form config + if ( + deployment.authType !== 'public' && + authCookie && + validateAuthToken(authCookie.value, deployment.id, deployment.password) + ) { + return addCorsHeaders( + createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + showBranding: deployment.showBranding, + inputSchema, + }), + request + ) + } + + // Check authentication requirement + const authResult = await validateFormAuth(requestId, deployment, request) + if (!authResult.authorized) { + // Return limited info for auth required forms + logger.info( + `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}` + ) + return addCorsHeaders( + NextResponse.json( + { + success: false, + error: authResult.error || 'Authentication required', + authType: deployment.authType, + title: deployment.title, + customizations: { + primaryColor: (deployment.customizations as any)?.primaryColor, + logoUrl: (deployment.customizations as any)?.logoUrl, + }, + }, + { status: 401 } + ), + request + ) + } - // If authenticated (via cookie), return full form config - if ( - deployment.authType !== 'public' && - authCookie && - validateAuthToken(authCookie.value, deployment.id, deployment.password) - ) { return addCorsHeaders( createSuccessResponse({ id: deployment.id, @@ -351,54 +401,16 @@ export async function GET( }), request ) - } - - // Check authentication requirement - const authResult = await validateFormAuth(requestId, deployment, request) - if (!authResult.authorized) { - // Return limited info for auth required forms - logger.info( - `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}` - ) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching form info:`, error) return addCorsHeaders( - NextResponse.json( - { - success: false, - error: authResult.error || 'Authentication required', - authType: deployment.authType, - title: deployment.title, - customizations: { - primaryColor: (deployment.customizations as any)?.primaryColor, - logoUrl: (deployment.customizations as any)?.logoUrl, - }, - }, - { status: 401 } - ), + createErrorResponse(error.message || 'Failed to fetch form information', 500), request ) } - - return addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - showBranding: deployment.showBranding, - inputSchema, - }), - request - ) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching form info:`, error) - return addCorsHeaders( - createErrorResponse(error.message || 'Failed to fetch form information', 500), - request - ) } -} +) -export async function OPTIONS(request: NextRequest) { +export const OPTIONS = withRouteHandler(async (request: NextRequest) => { return addCorsHeaders(new NextResponse(null, { status: 204 }), request) -} +}) diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index 577363b8d9c..428ff6f7007 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -63,206 +64,209 @@ const updateFormSchema = z.object({ isActive: z.boolean().optional(), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() - if (!session) { - return createErrorResponse('Unauthorized', 401) - } + if (!session) { + return createErrorResponse('Unauthorized', 401) + } - const { id } = await params + const { id } = await params - const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) + const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) - if (!hasAccess || !formRecord) { - return createErrorResponse('Form not found or access denied', 404) - } + if (!hasAccess || !formRecord) { + return createErrorResponse('Form not found or access denied', 404) + } - const { password: _password, ...formWithoutPassword } = formRecord + const { password: _password, ...formWithoutPassword } = formRecord - return createSuccessResponse({ - form: { - ...formWithoutPassword, - hasPassword: !!formRecord.password, - }, - }) - } catch (error: any) { - logger.error('Error fetching form:', error) - return createErrorResponse(error.message || 'Failed to fetch form', 500) + return createSuccessResponse({ + form: { + ...formWithoutPassword, + hasPassword: !!formRecord.password, + }, + }) + } catch (error: any) { + logger.error('Error fetching form:', error) + return createErrorResponse(error.message || 'Failed to fetch form', 500) + } } -} +) -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() - if (!session) { - return createErrorResponse('Unauthorized', 401) - } + if (!session) { + return createErrorResponse('Unauthorized', 401) + } - const { id } = await params + const { id } = await params - const { - hasAccess, - form: formRecord, - workspaceId: formWorkspaceId, - } = await checkFormAccess(id, session.user.id) + const { + hasAccess, + form: formRecord, + workspaceId: formWorkspaceId, + } = await checkFormAccess(id, session.user.id) - if (!hasAccess || !formRecord) { - return createErrorResponse('Form not found or access denied', 404) - } + if (!hasAccess || !formRecord) { + return createErrorResponse('Form not found or access denied', 404) + } - const body = await request.json() + const body = await request.json() + + try { + const validatedData = updateFormSchema.parse(body) + + const { + identifier, + title, + description, + customizations, + authType, + password, + allowedEmails, + showBranding, + isActive, + } = validatedData + + if (identifier && identifier !== formRecord.identifier) { + const existingIdentifier = await db + .select() + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1) + + if (existingIdentifier.length > 0) { + return createErrorResponse('Identifier already in use', 400) + } + } - try { - const validatedData = updateFormSchema.parse(body) + if (authType === 'password' && !password && !formRecord.password) { + return createErrorResponse('Password is required when using password protection', 400) + } - const { - identifier, - title, - description, - customizations, - authType, - password, - allowedEmails, - showBranding, - isActive, - } = validatedData - - if (identifier && identifier !== formRecord.identifier) { - const existingIdentifier = await db - .select() - .from(form) - .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) - .limit(1) - - if (existingIdentifier.length > 0) { - return createErrorResponse('Identifier already in use', 400) + if ( + authType === 'email' && + (!allowedEmails || allowedEmails.length === 0) && + (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0) + ) { + return createErrorResponse( + 'At least one email or domain is required when using email access control', + 400 + ) } - } - if (authType === 'password' && !password && !formRecord.password) { - return createErrorResponse('Password is required when using password protection', 400) - } + const updateData: Record = { + updatedAt: new Date(), + } - if ( - authType === 'email' && - (!allowedEmails || allowedEmails.length === 0) && - (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0) - ) { - return createErrorResponse( - 'At least one email or domain is required when using email access control', - 400 - ) - } + if (identifier !== undefined) updateData.identifier = identifier + if (title !== undefined) updateData.title = title + if (description !== undefined) updateData.description = description + if (showBranding !== undefined) updateData.showBranding = showBranding + if (isActive !== undefined) updateData.isActive = isActive + if (authType !== undefined) updateData.authType = authType + if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails + + if (customizations !== undefined) { + const existingCustomizations = (formRecord.customizations as Record) || {} + updateData.customizations = { + ...DEFAULT_FORM_CUSTOMIZATIONS, + ...existingCustomizations, + ...customizations, + } + } - const updateData: Record = { - updatedAt: new Date(), - } + if (password) { + const { encrypted } = await encryptSecret(password) + updateData.password = encrypted + } else if (authType && authType !== 'password') { + updateData.password = null + } - if (identifier !== undefined) updateData.identifier = identifier - if (title !== undefined) updateData.title = title - if (description !== undefined) updateData.description = description - if (showBranding !== undefined) updateData.showBranding = showBranding - if (isActive !== undefined) updateData.isActive = isActive - if (authType !== undefined) updateData.authType = authType - if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails - - if (customizations !== undefined) { - const existingCustomizations = (formRecord.customizations as Record) || {} - updateData.customizations = { - ...DEFAULT_FORM_CUSTOMIZATIONS, - ...existingCustomizations, - ...customizations, + await db.update(form).set(updateData).where(eq(form.id, id)) + + logger.info(`Form ${id} updated successfully`) + + recordAudit({ + workspaceId: formWorkspaceId ?? null, + actorId: session.user.id, + action: AuditAction.FORM_UPDATED, + resourceType: AuditResourceType.FORM, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: formRecord.title ?? undefined, + description: `Updated form "${formRecord.title}"`, + request, + }) + + return createSuccessResponse({ + message: 'Form updated successfully', + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + const errorMessage = validationError.errors[0]?.message || 'Invalid request data' + return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') } + throw validationError + } + } catch (error: any) { + logger.error('Error updating form:', error) + return createErrorResponse(error.message || 'Failed to update form', 500) + } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) } - if (password) { - const { encrypted } = await encryptSecret(password) - updateData.password = encrypted - } else if (authType && authType !== 'password') { - updateData.password = null + const { id } = await params + + const { + hasAccess, + form: formRecord, + workspaceId: formWorkspaceId, + } = await checkFormAccess(id, session.user.id) + + if (!hasAccess || !formRecord) { + return createErrorResponse('Form not found or access denied', 404) } - await db.update(form).set(updateData).where(eq(form.id, id)) + await db.delete(form).where(eq(form.id, id)) - logger.info(`Form ${id} updated successfully`) + logger.info(`Form ${id} deleted (soft delete)`) recordAudit({ workspaceId: formWorkspaceId ?? null, actorId: session.user.id, - action: AuditAction.FORM_UPDATED, + action: AuditAction.FORM_DELETED, resourceType: AuditResourceType.FORM, resourceId: id, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, resourceName: formRecord.title ?? undefined, - description: `Updated form "${formRecord.title}"`, + description: `Deleted form "${formRecord.title}"`, request, }) return createSuccessResponse({ - message: 'Form updated successfully', + message: 'Form deleted successfully', }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - const errorMessage = validationError.errors[0]?.message || 'Invalid request data' - return createErrorResponse(errorMessage, 400, 'VALIDATION_ERROR') - } - throw validationError + } catch (error: any) { + logger.error('Error deleting form:', error) + return createErrorResponse(error.message || 'Failed to delete form', 500) } - } catch (error: any) { - logger.error('Error updating form:', error) - return createErrorResponse(error.message || 'Failed to update form', 500) - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getSession() - - if (!session) { - return createErrorResponse('Unauthorized', 401) - } - - const { id } = await params - - const { - hasAccess, - form: formRecord, - workspaceId: formWorkspaceId, - } = await checkFormAccess(id, session.user.id) - - if (!hasAccess || !formRecord) { - return createErrorResponse('Form not found or access denied', 404) - } - - await db.delete(form).where(eq(form.id, id)) - - logger.info(`Form ${id} deleted (soft delete)`) - - recordAudit({ - workspaceId: formWorkspaceId ?? null, - actorId: session.user.id, - action: AuditAction.FORM_DELETED, - resourceType: AuditResourceType.FORM, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: formRecord.title ?? undefined, - description: `Deleted form "${formRecord.title}"`, - request, - }) - - return createSuccessResponse({ - message: 'Form deleted successfully', - }) - } catch (error: any) { - logger.error('Error deleting form:', error) - return createErrorResponse(error.message || 'Failed to delete form', 500) } -} +) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index 6512ba95808..d0368bbaaf7 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -10,6 +10,7 @@ import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' import { getEmailDomain } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deployWorkflow } from '@/lib/workflows/persistence/utils' import { checkWorkflowAccessForFormCreation, @@ -65,7 +66,7 @@ const formSchema = z.object({ showBranding: z.boolean().optional().default(true), }) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -83,9 +84,9 @@ export async function GET(request: NextRequest) { logger.error('Error fetching form deployments:', error) return createErrorResponse(error.message || 'Failed to fetch form deployments', 500) } -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() @@ -227,4 +228,4 @@ export async function POST(request: NextRequest) { logger.error('Error creating form deployment:', error) return createErrorResponse(error.message || 'Failed to create form deployment', 500) } -} +}) diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts index 0b2b8a076e7..9af0542314f 100644 --- a/apps/sim/app/api/form/validate/route.ts +++ b/apps/sim/app/api/form/validate/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormValidateAPI') @@ -20,7 +21,7 @@ const validateQuerySchema = z.object({ /** * GET endpoint to validate form identifier availability */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -68,4 +69,4 @@ export async function GET(request: NextRequest) { logger.error('Error validating form identifier:', error) return createErrorResponse(message, 500) } -} +}) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 24e992401b7..1b64b89ee98 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { isE2bEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' @@ -580,7 +581,7 @@ function cleanStdout(stdout: string): string { return stdout } -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() let stdout = '' @@ -969,4 +970,4 @@ export async function POST(req: NextRequest) { return NextResponse.json(errorResponse, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/guardrails/validate/route.ts b/apps/sim/app/api/guardrails/validate/route.ts index 82567422f26..02f4e4f1945 100644 --- a/apps/sim/app/api/guardrails/validate/route.ts +++ b/apps/sim/app/api/guardrails/validate/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateHallucination } from '@/lib/guardrails/validate_hallucination' import { validateJson } from '@/lib/guardrails/validate_json' import { validatePII } from '@/lib/guardrails/validate_pii' @@ -9,7 +10,7 @@ import { validateRegex } from '@/lib/guardrails/validate_regex' const logger = createLogger('GuardrailsValidateAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Guardrails validation request received`) @@ -179,7 +180,7 @@ export async function POST(request: NextRequest) { }, }) } -} +}) /** * Convert input to string for validation diff --git a/apps/sim/app/api/help/integration-request/route.ts b/apps/sim/app/api/help/integration-request/route.ts index ea3c4af437d..cf3c33e0499 100644 --- a/apps/sim/app/api/help/integration-request/route.ts +++ b/apps/sim/app/api/help/integration-request/route.ts @@ -6,6 +6,7 @@ import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, @@ -33,7 +34,7 @@ const integrationRequestSchema = z.object({ useCase: z.string().max(2000).optional(), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -115,4 +116,4 @@ ${useCase ? `Use Case:\n${useCase}` : 'No use case provided.'} logger.error(`[${requestId}] Error processing integration request`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index a028bd4005f..e396d30aadc 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getEmailDomain } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, @@ -24,7 +25,7 @@ const helpFormSchema = z.object({ type: z.enum(['bug', 'feedback', 'feature_request', 'other']), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -159,4 +160,4 @@ ${message} logger.error(`[${requestId}] Error processing help request`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 0ce749fa828..ecd1f52920d 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -3,86 +3,86 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getJobQueue } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { presentDispatchOrJobStatus } from '@/lib/core/workspace-dispatch/status' import { getDispatchJobRecord } from '@/lib/core/workspace-dispatch/store' import { createErrorResponse } from '@/app/api/workflows/utils' const logger = createLogger('TaskStatusAPI') -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ jobId: string }> } -) { - const { jobId: taskId } = await params - const requestId = generateRequestId() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ jobId: string }> }) => { + const { jobId: taskId } = await params + const requestId = generateRequestId() - try { - const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized task status request`) - return createErrorResponse(authResult.error || 'Authentication required', 401) - } + try { + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized task status request`) + return createErrorResponse(authResult.error || 'Authentication required', 401) + } - const authenticatedUserId = authResult.userId + const authenticatedUserId = authResult.userId - const dispatchJob = await getDispatchJobRecord(taskId) - const jobQueue = await getJobQueue() - const job = dispatchJob ? null : await jobQueue.getJob(taskId) + const dispatchJob = await getDispatchJobRecord(taskId) + const jobQueue = await getJobQueue() + const job = dispatchJob ? null : await jobQueue.getJob(taskId) - if (!job && !dispatchJob) { - return createErrorResponse('Task not found', 404) - } + if (!job && !dispatchJob) { + return createErrorResponse('Task not found', 404) + } - const metadataToCheck = dispatchJob?.metadata ?? job?.metadata + const metadataToCheck = dispatchJob?.metadata ?? job?.metadata - if (metadataToCheck?.workflowId) { - const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') - const accessCheck = await verifyWorkflowAccess( - authenticatedUserId, - metadataToCheck.workflowId as string - ) - if (!accessCheck.hasAccess) { - logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`) + if (metadataToCheck?.workflowId) { + const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') + const accessCheck = await verifyWorkflowAccess( + authenticatedUserId, + metadataToCheck.workflowId as string + ) + if (!accessCheck.hasAccess) { + logger.warn(`[${requestId}] Access denied to workflow ${metadataToCheck.workflowId}`) + return createErrorResponse('Access denied', 403) + } + + if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) { + const { getWorkflowById } = await import('@/lib/workflows/utils') + const workflow = await getWorkflowById(metadataToCheck.workflowId as string) + if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) { + return createErrorResponse('API key is not authorized for this workspace', 403) + } + } + } else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) { + logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`) + return createErrorResponse('Access denied', 403) + } else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) { + logger.warn(`[${requestId}] Access denied to job ${taskId}`) return createErrorResponse('Access denied', 403) } - if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) { - const { getWorkflowById } = await import('@/lib/workflows/utils') - const workflow = await getWorkflowById(metadataToCheck.workflowId as string) - if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) { - return createErrorResponse('API key is not authorized for this workspace', 403) - } + const presented = presentDispatchOrJobStatus(dispatchJob, job) + const response: any = { + success: true, + taskId, + status: presented.status, + metadata: presented.metadata, } - } else if (metadataToCheck?.userId && metadataToCheck.userId !== authenticatedUserId) { - logger.warn(`[${requestId}] Access denied to user ${metadataToCheck.userId}`) - return createErrorResponse('Access denied', 403) - } else if (!metadataToCheck?.userId && !metadataToCheck?.workflowId) { - logger.warn(`[${requestId}] Access denied to job ${taskId}`) - return createErrorResponse('Access denied', 403) - } - const presented = presentDispatchOrJobStatus(dispatchJob, job) - const response: any = { - success: true, - taskId, - status: presented.status, - metadata: presented.metadata, - } + if (presented.output !== undefined) response.output = presented.output + if (presented.error !== undefined) response.error = presented.error + if (presented.estimatedDuration !== undefined) { + response.estimatedDuration = presented.estimatedDuration + } - if (presented.output !== undefined) response.output = presented.output - if (presented.error !== undefined) response.error = presented.error - if (presented.estimatedDuration !== undefined) { - response.estimatedDuration = presented.estimatedDuration - } + return NextResponse.json(response) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching task status:`, error) - return NextResponse.json(response) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching task status:`, error) + if (error.message?.includes('not found') || error.status === 404) { + return createErrorResponse('Task not found', 404) + } - if (error.message?.includes('not found') || error.status === 404) { - return createErrorResponse('Task not found', 404) + return createErrorResponse('Failed to fetch task status', 500) } - - return createErrorResponse('Failed to fetch task status', 500) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts index 48e0d0deb2d..ef1c2908747 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('ConnectorDocumentsAPI') @@ -17,7 +18,7 @@ type RouteParams = { params: Promise<{ id: string; connectorId: string }> } * GET /api/knowledge/[id]/connectors/[connectorId]/documents * Returns documents for a connector, optionally including user-excluded ones. */ -export async function GET(request: NextRequest, { params }: RouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { id: knowledgeBaseId, connectorId } = await params @@ -113,7 +114,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { logger.error(`[${requestId}] Error fetching connector documents`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) const PatchSchema = z.object({ operation: z.enum(['restore', 'exclude']), @@ -124,7 +125,7 @@ const PatchSchema = z.object({ * PATCH /api/knowledge/[id]/connectors/[connectorId]/documents * Restore or exclude connector documents. */ -export async function PATCH(request: NextRequest, { params }: RouteParams) { +export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { id: knowledgeBaseId, connectorId } = await params @@ -241,4 +242,4 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { logger.error(`[${requestId}] Error updating connector documents`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 87cdb51a737..9b41748a452 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -15,6 +15,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { hasLiveSyncAccess } from '@/lib/billing/core/subscription' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service' import { captureServerEvent } from '@/lib/posthog/server' @@ -35,7 +36,7 @@ const UpdateConnectorSchema = z.object({ /** * GET /api/knowledge/[id]/connectors/[connectorId] - Get connector details with recent sync logs */ -export async function GET(request: NextRequest, { params }: RouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { id: knowledgeBaseId, connectorId } = await params @@ -87,12 +88,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { logger.error(`[${requestId}] Error fetching connector`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) /** * PATCH /api/knowledge/[id]/connectors/[connectorId] - Update a connector */ -export async function PATCH(request: NextRequest, { params }: RouteParams) { +export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { id: knowledgeBaseId, connectorId } = await params @@ -277,12 +278,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { logger.error(`[${requestId}] Error updating connector`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) /** * DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector */ -export async function DELETE(request: NextRequest, { params }: RouteParams) { +export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { id: knowledgeBaseId, connectorId } = await params @@ -410,4 +411,4 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { logger.error(`[${requestId}] Error deleting connector`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index df7057fc904..67dcc92659a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' import { captureServerEvent } from '@/lib/posthog/server' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -17,7 +18,7 @@ type RouteParams = { params: Promise<{ id: string; connectorId: string }> } /** * POST /api/knowledge/[id]/connectors/[connectorId]/sync - Trigger a manual sync */ -export async function POST(request: NextRequest, { params }: RouteParams) { +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { id: knowledgeBaseId, connectorId } = await params @@ -97,4 +98,4 @@ export async function POST(request: NextRequest, { params }: RouteParams) { logger.error(`[${requestId}] Error triggering manual sync`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index b5e2cb86f46..70ee0b63c43 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -10,6 +10,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { hasLiveSyncAccess } from '@/lib/billing/core/subscription' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' import { allocateTagSlots } from '@/lib/knowledge/constants' import { createTagDefinition } from '@/lib/knowledge/tags/service' @@ -31,285 +32,295 @@ const CreateConnectorSchema = z.object({ /** * GET /api/knowledge/[id]/connectors - List connectors for a knowledge base */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id: knowledgeBaseId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) + if (!accessCheck.hasAccess) { + const status = 'notFound' in accessCheck && accessCheck.notFound ? 404 : 401 + return NextResponse.json( + { error: status === 404 ? 'Not found' : 'Unauthorized' }, + { status } + ) + } - const connectors = await db - .select() - .from(knowledgeConnector) - .where( - and( - eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) + const connectors = await db + .select() + .from(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) ) - ) - .orderBy(desc(knowledgeConnector.createdAt)) - - return NextResponse.json({ - success: true, - data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest), - }) - } catch (error) { - logger.error(`[${requestId}] Error listing connectors`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + .orderBy(desc(knowledgeConnector.createdAt)) + + return NextResponse.json({ + success: true, + data: connectors.map(({ encryptedApiKey: _, ...rest }) => rest), + }) + } catch (error) { + logger.error(`[${requestId}] Error listing connectors`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * POST /api/knowledge/[id]/connectors - Create a new connector */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id: knowledgeBaseId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!writeCheck.hasAccess) { - const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 - return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) - } + const writeCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!writeCheck.hasAccess) { + const status = 'notFound' in writeCheck && writeCheck.notFound ? 404 : 401 + return NextResponse.json( + { error: status === 404 ? 'Not found' : 'Unauthorized' }, + { status } + ) + } - const body = await request.json() - const parsed = CreateConnectorSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid request', details: parsed.error.flatten() }, - { status: 400 } - ) - } + const body = await request.json() + const parsed = CreateConnectorSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request', details: parsed.error.flatten() }, + { status: 400 } + ) + } - const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data + const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data - if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) { - const canUseLiveSync = await hasLiveSyncAccess(auth.userId) - if (!canUseLiveSync) { + if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) { + const canUseLiveSync = await hasLiveSyncAccess(auth.userId) + if (!canUseLiveSync) { + return NextResponse.json( + { error: 'Live sync requires a Max or Enterprise plan' }, + { status: 403 } + ) + } + } + + const connectorConfig = CONNECTOR_REGISTRY[connectorType] + if (!connectorConfig) { return NextResponse.json( - { error: 'Live sync requires a Max or Enterprise plan' }, - { status: 403 } + { error: `Unknown connector type: ${connectorType}` }, + { status: 400 } ) } - } - const connectorConfig = CONNECTOR_REGISTRY[connectorType] - if (!connectorConfig) { - return NextResponse.json( - { error: `Unknown connector type: ${connectorType}` }, - { status: 400 } - ) - } + let resolvedCredentialId: string | null = null + let resolvedEncryptedApiKey: string | null = null + let accessToken: string - let resolvedCredentialId: string | null = null - let resolvedEncryptedApiKey: string | null = null - let accessToken: string + if (connectorConfig.auth.mode === 'apiKey') { + if (!apiKey) { + return NextResponse.json({ error: 'API key is required' }, { status: 400 }) + } + accessToken = apiKey + } else { + if (!credentialId) { + return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) + } - if (connectorConfig.auth.mode === 'apiKey') { - if (!apiKey) { - return NextResponse.json({ error: 'API key is required' }, { status: 400 }) - } - accessToken = apiKey - } else { - if (!credentialId) { - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } + const credential = await getCredential(requestId, credentialId, auth.userId) + if (!credential) { + return NextResponse.json({ error: 'Credential not found' }, { status: 400 }) + } - const credential = await getCredential(requestId, credentialId, auth.userId) - if (!credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 400 }) + if (!credential.accessToken) { + return NextResponse.json( + { error: 'Credential has no access token. Please reconnect your account.' }, + { status: 400 } + ) + } + + accessToken = credential.accessToken + resolvedCredentialId = credentialId } - if (!credential.accessToken) { + const validation = await connectorConfig.validateConfig(accessToken, sourceConfig) + if (!validation.valid) { return NextResponse.json( - { error: 'Credential has no access token. Please reconnect your account.' }, + { error: validation.error || 'Invalid source configuration' }, { status: 400 } ) } - accessToken = credential.accessToken - resolvedCredentialId = credentialId - } - - const validation = await connectorConfig.validateConfig(accessToken, sourceConfig) - if (!validation.valid) { - return NextResponse.json( - { error: validation.error || 'Invalid source configuration' }, - { status: 400 } - ) - } + let finalSourceConfig: Record = { ...sourceConfig } - let finalSourceConfig: Record = { ...sourceConfig } - - if (connectorConfig.auth.mode === 'apiKey' && apiKey) { - const { encrypted } = await encryptApiKey(apiKey) - resolvedEncryptedApiKey = encrypted - } + if (connectorConfig.auth.mode === 'apiKey' && apiKey) { + const { encrypted } = await encryptApiKey(apiKey) + resolvedEncryptedApiKey = encrypted + } - const tagSlotMapping: Record = {} - let newTagSlots: Record = {} + const tagSlotMapping: Record = {} + let newTagSlots: Record = {} + + if (connectorConfig.tagDefinitions?.length) { + const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? []) + const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id)) + + const existingDefs = await db + .select({ + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + fieldType: knowledgeBaseTagDefinitions.fieldType, + }) + .from(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) + + const usedSlots = new Set(existingDefs.map((d) => d.tagSlot)) + const existingByName = new Map( + existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }]) + ) - if (connectorConfig.tagDefinitions?.length) { - const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? []) - const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id)) + const defsNeedingSlots: typeof enabledDefs = [] + for (const td of enabledDefs) { + const existing = existingByName.get(td.displayName) + if (existing && existing.fieldType === td.fieldType) { + tagSlotMapping[td.id] = existing.tagSlot + } else { + defsNeedingSlots.push(td) + } + } - const existingDefs = await db - .select({ - tagSlot: knowledgeBaseTagDefinitions.tagSlot, - displayName: knowledgeBaseTagDefinitions.displayName, - fieldType: knowledgeBaseTagDefinitions.fieldType, - }) - .from(knowledgeBaseTagDefinitions) - .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) + const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots) + Object.assign(tagSlotMapping, mapping) + newTagSlots = mapping - const usedSlots = new Set(existingDefs.map((d) => d.tagSlot)) - const existingByName = new Map( - existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }]) - ) + for (const name of skippedTags) { + logger.warn(`[${requestId}] No available slots for "${name}"`) + } - const defsNeedingSlots: typeof enabledDefs = [] - for (const td of enabledDefs) { - const existing = existingByName.get(td.displayName) - if (existing && existing.fieldType === td.fieldType) { - tagSlotMapping[td.id] = existing.tagSlot - } else { - defsNeedingSlots.push(td) + if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { + return NextResponse.json( + { error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` }, + { status: 422 } + ) } + + finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } } - const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots) - Object.assign(tagSlotMapping, mapping) - newTagSlots = mapping + const now = new Date() + const connectorId = generateId() + const nextSyncAt = + syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null - for (const name of skippedTags) { - logger.warn(`[${requestId}] No available slots for "${name}"`) - } + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - if (skippedTags.length > 0 && Object.keys(tagSlotMapping).length === 0) { - return NextResponse.json( - { error: `No available tag slots. Could not assign: ${skippedTags.join(', ')}` }, - { status: 422 } - ) - } + const activeKb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) - finalSourceConfig = { ...finalSourceConfig, tagSlotMapping } - } + if (activeKb.length === 0) { + throw new Error('Knowledge base not found') + } - const now = new Date() - const connectorId = generateId() - const nextSyncAt = - syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null + for (const [semanticId, slot] of Object.entries(newTagSlots)) { + const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)! + await createTagDefinition( + { + knowledgeBaseId, + tagSlot: slot, + displayName: td.displayName, + fieldType: td.fieldType, + }, + requestId, + tx + ) + } - await db.transaction(async (tx) => { - await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + await tx.insert(knowledgeConnector).values({ + id: connectorId, + knowledgeBaseId, + connectorType, + credentialId: resolvedCredentialId, + encryptedApiKey: resolvedEncryptedApiKey, + sourceConfig: finalSourceConfig, + syncIntervalMinutes, + status: 'active', + nextSyncAt, + createdAt: now, + updatedAt: now, + }) + }) - const activeKb = await tx - .select({ id: knowledgeBase.id }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) + logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`) + + const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? '' + captureServerEvent( + auth.userId, + 'knowledge_base_connector_added', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId, + connector_type: connectorType, + sync_interval_minutes: syncIntervalMinutes, + }, + { + groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined, + setOnce: { first_connector_added_at: new Date().toISOString() }, + } + ) - if (activeKb.length === 0) { - throw new Error('Knowledge base not found') - } + recordAudit({ + workspaceId: writeCheck.knowledgeBase.workspaceId, + actorId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.CONNECTOR_CREATED, + resourceType: AuditResourceType.CONNECTOR, + resourceId: connectorId, + resourceName: connectorType, + description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`, + metadata: { knowledgeBaseId, connectorType, syncIntervalMinutes }, + request, + }) - for (const [semanticId, slot] of Object.entries(newTagSlots)) { - const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)! - await createTagDefinition( - { - knowledgeBaseId, - tagSlot: slot, - displayName: td.displayName, - fieldType: td.fieldType, - }, - requestId, - tx + dispatchSync(connectorId, { requestId }).catch((error) => { + logger.error( + `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, + error ) - } - - await tx.insert(knowledgeConnector).values({ - id: connectorId, - knowledgeBaseId, - connectorType, - credentialId: resolvedCredentialId, - encryptedApiKey: resolvedEncryptedApiKey, - sourceConfig: finalSourceConfig, - syncIntervalMinutes, - status: 'active', - nextSyncAt, - createdAt: now, - updatedAt: now, }) - }) - - logger.info(`[${requestId}] Created connector ${connectorId} for KB ${knowledgeBaseId}`) - - const kbWorkspaceId = writeCheck.knowledgeBase.workspaceId ?? '' - captureServerEvent( - auth.userId, - 'knowledge_base_connector_added', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId, - connector_type: connectorType, - sync_interval_minutes: syncIntervalMinutes, - }, - { - groups: kbWorkspaceId ? { workspace: kbWorkspaceId } : undefined, - setOnce: { first_connector_added_at: new Date().toISOString() }, + + const created = await db + .select() + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connectorId)) + .limit(1) + + const { encryptedApiKey: _, ...createdData } = created[0] + return NextResponse.json({ success: true, data: createdData }, { status: 201 }) + } catch (error) { + if (error instanceof Error && error.message === 'Knowledge base not found') { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - ) - - recordAudit({ - workspaceId: writeCheck.knowledgeBase.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.CONNECTOR_CREATED, - resourceType: AuditResourceType.CONNECTOR, - resourceId: connectorId, - resourceName: connectorType, - description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { knowledgeBaseId, connectorType, syncIntervalMinutes }, - request, - }) - - dispatchSync(connectorId, { requestId }).catch((error) => { - logger.error( - `[${requestId}] Failed to dispatch initial sync for connector ${connectorId}`, - error - ) - }) - - const created = await db - .select() - .from(knowledgeConnector) - .where(eq(knowledgeConnector.id, connectorId)) - .limit(1) - - const { encryptedApiKey: _, ...createdData } = created[0] - return NextResponse.json({ success: true, data: createdData }, { status: 201 }) - } catch (error) { - if (error instanceof Error && error.message === 'Knowledge base not found') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + logger.error(`[${requestId}] Error creating connector`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - logger.error(`[${requestId}] Error creating connector`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index b992ca4b4fe..0cd97ae5ac8 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteChunk, updateChunk } from '@/lib/knowledge/chunks/service' import { checkChunkAccess } from '@/app/api/knowledge/utils' @@ -13,192 +14,198 @@ const UpdateChunkSchema = z.object({ enabled: z.boolean().optional(), }) -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized chunk access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkChunkAccess( - knowledgeBaseId, - documentId, - chunkId, - session.user.id - ) +export const GET = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } + ) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, documentId, chunkId } = await params - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { - logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` - ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized chunk access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized chunk access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - logger.info( - `[${requestId}] Retrieved chunk: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` - ) - - return NextResponse.json({ - success: true, - data: accessCheck.chunk, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching chunk`, error) - return NextResponse.json({ error: 'Failed to fetch chunk' }, { status: 500 }) - } -} - -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized chunk update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkChunkAccess( - knowledgeBaseId, - documentId, - chunkId, - session.user.id - ) + const accessCheck = await checkChunkAccess( + knowledgeBaseId, + documentId, + chunkId, + session.user.id + ) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` + `[${requestId}] User ${session.user.id} attempted unauthorized chunk access: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized chunk update: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - if (accessCheck.document?.connectorId) { - logger.warn( - `[${requestId}] User ${session.user.id} attempted to update chunk on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } + logger.info( + `[${requestId}] Retrieved chunk: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` ) + + return NextResponse.json({ + success: true, + data: accessCheck.chunk, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching chunk`, error) + return NextResponse.json({ error: 'Failed to fetch chunk' }, { status: 500 }) } + } +) - const body = await req.json() +export const PUT = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } + ) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, documentId, chunkId } = await params try { - const validatedData = UpdateChunkSchema.parse(body) + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized chunk update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const updatedChunk = await updateChunk( + const accessCheck = await checkChunkAccess( + knowledgeBaseId, + documentId, chunkId, - validatedData, - requestId, - accessCheck.knowledgeBase?.workspaceId + session.user.id ) - logger.info( - `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` - ) + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${session.user.id} attempted unauthorized chunk update: ${accessCheck.reason}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - return NextResponse.json({ - success: true, - data: updatedChunk, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid chunk update data`, { - errors: validationError.errors, - }) + if (accessCheck.document?.connectorId) { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to update chunk on connector-synced document: Doc=${documentId}` + ) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } + { error: 'Chunks from connector-synced documents are read-only' }, + { status: 403 } ) } - throw validationError + + const body = await req.json() + + try { + const validatedData = UpdateChunkSchema.parse(body) + + const updatedChunk = await updateChunk( + chunkId, + validatedData, + requestId, + accessCheck.knowledgeBase?.workspaceId + ) + + logger.info( + `[${requestId}] Chunk updated: ${chunkId} in document ${documentId} in knowledge base ${knowledgeBaseId}` + ) + + return NextResponse.json({ + success: true, + data: updatedChunk, + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid chunk update data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error updating chunk`, error) + return NextResponse.json({ error: 'Failed to update chunk' }, { status: 500 }) } - } catch (error) { - logger.error(`[${requestId}] Error updating chunk`, error) - return NextResponse.json({ error: 'Failed to update chunk' }, { status: 500 }) } -} - -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId, chunkId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized chunk delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +) + +export const DELETE = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; documentId: string; chunkId: string }> } + ) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, documentId, chunkId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized chunk delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const accessCheck = await checkChunkAccess( - knowledgeBaseId, - documentId, - chunkId, - session.user.id - ) + const accessCheck = await checkChunkAccess( + knowledgeBaseId, + documentId, + chunkId, + session.user.id + ) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}, Chunk=${chunkId}` + `[${requestId}] User ${session.user.id} attempted unauthorized chunk deletion: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized chunk deletion: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - if (accessCheck.document?.connectorId) { - logger.warn( - `[${requestId}] User ${session.user.id} attempted to delete chunk on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) - } + if (accessCheck.document?.connectorId) { + logger.warn( + `[${requestId}] User ${session.user.id} attempted to delete chunk on connector-synced document: Doc=${documentId}` + ) + return NextResponse.json( + { error: 'Chunks from connector-synced documents are read-only' }, + { status: 403 } + ) + } - await deleteChunk(chunkId, documentId, requestId) + await deleteChunk(chunkId, documentId, requestId) - logger.info( - `[${requestId}] Chunk deleted: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` - ) + logger.info( + `[${requestId}] Chunk deleted: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` + ) - return NextResponse.json({ - success: true, - data: { message: 'Chunk deleted successfully' }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting chunk`, error) - return NextResponse.json({ error: 'Failed to delete chunk' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: { message: 'Chunk deleted successfully' }, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting chunk`, error) + return NextResponse.json({ error: 'Failed to delete chunk' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 4eaa3353f1d..a3bc917402b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchChunkOperation, createChunk, queryChunks } from '@/lib/knowledge/chunks/service' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils' @@ -32,313 +33,310 @@ const BatchOperationSchema = z.object({ .max(100, 'Cannot operate on more than 100 chunks at once'), }) -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized chunks access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) + try { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized chunks access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) + + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${userId} attempted unauthorized chunks access: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized chunks access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const doc = accessCheck.document - if (!doc) { - logger.warn( - `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - if (doc.processingStatus !== 'completed') { - logger.warn( - `[${requestId}] Document ${documentId} is not ready for chunk access (status: ${doc.processingStatus})` - ) - return NextResponse.json( - { - error: 'Document is not ready for access', - details: `Document status: ${doc.processingStatus}`, - retryAfter: doc.processingStatus === 'processing' ? 5 : null, - }, - { status: 400 } - ) - } - - const { searchParams } = new URL(req.url) - const queryParams = GetChunksQuerySchema.parse({ - search: searchParams.get('search') || undefined, - enabled: searchParams.get('enabled') || undefined, - limit: searchParams.get('limit') || undefined, - offset: searchParams.get('offset') || undefined, - sortBy: searchParams.get('sortBy') || undefined, - sortOrder: searchParams.get('sortOrder') || undefined, - }) - - const result = await queryChunks(documentId, queryParams, requestId) - - return NextResponse.json({ - success: true, - data: result.chunks, - pagination: result.pagination, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching chunks`, error) - return NextResponse.json({ error: 'Failed to fetch chunks' }, { status: 500 }) - } -} - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const body = await req.json() - const { workflowId, ...searchParams } = body - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } + const doc = accessCheck.document + if (!doc) { + logger.warn( + `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` ) + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) } - } - - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + if (doc.processingStatus !== 'completed') { logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] Document ${documentId} is not ready for chunk access (status: ${doc.processingStatus})` + ) + return NextResponse.json( + { + error: 'Document is not ready for access', + details: `Document status: ${doc.processingStatus}`, + retryAfter: doc.processingStatus === 'processing' ? 5 : null, + }, + { status: 400 } ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized chunk creation: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const doc = accessCheck.document - if (!doc) { - logger.warn( - `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` - ) - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } + const { searchParams } = new URL(req.url) + const queryParams = GetChunksQuerySchema.parse({ + search: searchParams.get('search') || undefined, + enabled: searchParams.get('enabled') || undefined, + limit: searchParams.get('limit') || undefined, + offset: searchParams.get('offset') || undefined, + sortBy: searchParams.get('sortBy') || undefined, + sortOrder: searchParams.get('sortOrder') || undefined, + }) - if (doc.connectorId) { - logger.warn( - `[${requestId}] User ${userId} attempted to create chunk on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) - } + const result = await queryChunks(documentId, queryParams, requestId) - // Allow manual chunk creation even if document is not fully processed - // but it should exist and not be in failed state - if (doc.processingStatus === 'failed') { - logger.warn(`[${requestId}] Document ${documentId} is in failed state, cannot add chunks`) - return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 }) + return NextResponse.json({ + success: true, + data: result.chunks, + pagination: result.pagination, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching chunks`, error) + return NextResponse.json({ error: 'Failed to fetch chunks' }, { status: 500 }) } + } +) + +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params try { - const validatedData = CreateChunkSchema.parse(searchParams) - - const docTags = { - // Text tags (7 slots) - tag1: doc.tag1 ?? null, - tag2: doc.tag2 ?? null, - tag3: doc.tag3 ?? null, - tag4: doc.tag4 ?? null, - tag5: doc.tag5 ?? null, - tag6: doc.tag6 ?? null, - tag7: doc.tag7 ?? null, - // Number tags (5 slots) - number1: doc.number1 ?? null, - number2: doc.number2 ?? null, - number3: doc.number3 ?? null, - number4: doc.number4 ?? null, - number5: doc.number5 ?? null, - // Date tags (2 slots) - date1: doc.date1 ?? null, - date2: doc.date2 ?? null, - // Boolean tags (3 slots) - boolean1: doc.boolean1 ?? null, - boolean2: doc.boolean2 ?? null, - boolean3: doc.boolean3 ?? null, - } + const body = await req.json() + const { workflowId, ...searchParams } = body - const newChunk = await createChunk( - knowledgeBaseId, - documentId, - docTags, - validatedData, - requestId, - accessCheck.knowledgeBase?.workspaceId - ) + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - let cost = null - try { - cost = calculateCost('text-embedding-3-small', newChunk.tokenCount, 0, false) - } catch (error) { - logger.warn(`[${requestId}] Failed to calculate cost for chunk upload`, { - error: error instanceof Error ? error.message : 'Unknown error', + if (workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', }) - // Continue without cost information rather than failing the upload + if (!authorization.allowed) { + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status } + ) + } } - return NextResponse.json({ - success: true, - data: { - ...newChunk, - documentId, - documentName: doc.filename, - ...(cost - ? { - cost: { - input: cost.input, - output: cost.output, - total: cost.total, - tokens: { - prompt: newChunk.tokenCount, - completion: 0, - total: newChunk.tokenCount, - }, - model: 'text-embedding-3-small', - pricing: cost.pricing, - }, - } - : {}), - }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid chunk creation data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } + const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) + + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted unauthorized chunk creation: ${accessCheck.reason}` ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - throw validationError - } - } catch (error) { - logger.error(`[${requestId}] Error creating chunk`, error) - return NextResponse.json({ error: 'Failed to create chunk' }, { status: 500 }) - } -} - -export async function PATCH( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized batch chunk operation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) + const doc = accessCheck.document + if (!doc) { + logger.warn( + `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + if (doc.connectorId) { logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${userId} attempted to create chunk on connector-synced document: Doc=${documentId}` + ) + return NextResponse.json( + { error: 'Chunks from connector-synced documents are read-only' }, + { status: 403 } ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized batch chunk operation: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - if (accessCheck.document?.connectorId) { - logger.warn( - `[${requestId}] User ${userId} attempted batch chunk operation on connector-synced document: Doc=${documentId}` - ) - return NextResponse.json( - { error: 'Chunks from connector-synced documents are read-only' }, - { status: 403 } - ) + // Allow manual chunk creation even if document is not fully processed + // but it should exist and not be in failed state + if (doc.processingStatus === 'failed') { + logger.warn(`[${requestId}] Document ${documentId} is in failed state, cannot add chunks`) + return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 }) + } + + try { + const validatedData = CreateChunkSchema.parse(searchParams) + + const docTags = { + // Text tags (7 slots) + tag1: doc.tag1 ?? null, + tag2: doc.tag2 ?? null, + tag3: doc.tag3 ?? null, + tag4: doc.tag4 ?? null, + tag5: doc.tag5 ?? null, + tag6: doc.tag6 ?? null, + tag7: doc.tag7 ?? null, + // Number tags (5 slots) + number1: doc.number1 ?? null, + number2: doc.number2 ?? null, + number3: doc.number3 ?? null, + number4: doc.number4 ?? null, + number5: doc.number5 ?? null, + // Date tags (2 slots) + date1: doc.date1 ?? null, + date2: doc.date2 ?? null, + // Boolean tags (3 slots) + boolean1: doc.boolean1 ?? null, + boolean2: doc.boolean2 ?? null, + boolean3: doc.boolean3 ?? null, + } + + const newChunk = await createChunk( + knowledgeBaseId, + documentId, + docTags, + validatedData, + requestId, + accessCheck.knowledgeBase?.workspaceId + ) + + let cost = null + try { + cost = calculateCost('text-embedding-3-small', newChunk.tokenCount, 0, false) + } catch (error) { + logger.warn(`[${requestId}] Failed to calculate cost for chunk upload`, { + error: error instanceof Error ? error.message : 'Unknown error', + }) + // Continue without cost information rather than failing the upload + } + + return NextResponse.json({ + success: true, + data: { + ...newChunk, + documentId, + documentName: doc.filename, + ...(cost + ? { + cost: { + input: cost.input, + output: cost.output, + total: cost.total, + tokens: { + prompt: newChunk.tokenCount, + completion: 0, + total: newChunk.tokenCount, + }, + model: 'text-embedding-3-small', + pricing: cost.pricing, + }, + } + : {}), + }, + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid chunk creation data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error creating chunk`, error) + return NextResponse.json({ error: 'Failed to create chunk' }, { status: 500 }) } + } +) - const body = await req.json() +export const PATCH = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params try { - const validatedData = BatchOperationSchema.parse(body) - const { operation, chunkIds } = validatedData + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized batch chunk operation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - const result = await batchChunkOperation(documentId, operation, chunkIds, requestId) + const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) - return NextResponse.json({ - success: true, - data: { - operation, - successCount: result.processed, - errorCount: result.errors.length, - processed: result.processed, - errors: result.errors, - }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid batch operation data`, { - errors: validationError.errors, - }) + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted unauthorized batch chunk operation: ${accessCheck.reason}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + if (accessCheck.document?.connectorId) { + logger.warn( + `[${requestId}] User ${userId} attempted batch chunk operation on connector-synced document: Doc=${documentId}` + ) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } + { error: 'Chunks from connector-synced documents are read-only' }, + { status: 403 } ) } - throw validationError + + const body = await req.json() + + try { + const validatedData = BatchOperationSchema.parse(body) + const { operation, chunkIds } = validatedData + + const result = await batchChunkOperation(documentId, operation, chunkIds, requestId) + + return NextResponse.json({ + success: true, + data: { + operation, + successCount: result.processed, + errorCount: result.errors.length, + processed: result.processed, + errors: result.errors, + }, + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid batch operation data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error in batch chunk operation`, error) + return NextResponse.json({ error: 'Failed to perform batch operation' }, { status: 500 }) } - } catch (error) { - logger.error(`[${requestId}] Error in batch chunk operation`, error) - return NextResponse.json({ error: 'Failed to perform batch operation' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 4f8735826b1..e4055c80912 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocument, markDocumentAsFailedTimeout, @@ -48,258 +49,255 @@ const UpdateDocumentSchema = z.object({ boolean3: z.string().optional(), }) -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized document access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) + try { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized document access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId) + + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${userId} attempted unauthorized document access: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized document access: ${accessCheck.reason}` + + logger.info( + `[${requestId}] Retrieved document: ${documentId} from knowledge base ${knowledgeBaseId}` ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - logger.info( - `[${requestId}] Retrieved document: ${documentId} from knowledge base ${knowledgeBaseId}` - ) - - return NextResponse.json({ - success: true, - data: accessCheck.document, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching document`, error) - return NextResponse.json({ error: 'Failed to fetch document' }, { status: 500 }) - } -} - -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized document update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ + success: true, + data: accessCheck.document, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching document`, error) + return NextResponse.json({ error: 'Failed to fetch document' }, { status: 500 }) } - const userId = auth.userId + } +) + +export const PUT = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params + + try { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized document update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) + const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${userId} attempted unauthorized document update: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized document update: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const body = await req.json() + const body = await req.json() - try { - const validatedData = UpdateDocumentSchema.parse(body) + try { + const validatedData = UpdateDocumentSchema.parse(body) - const updateData: any = {} + const updateData: any = {} - if (validatedData.markFailedDueToTimeout) { - const doc = accessCheck.document + if (validatedData.markFailedDueToTimeout) { + const doc = accessCheck.document - if (doc.processingStatus !== 'processing') { - return NextResponse.json( - { error: `Document is not in processing state (current: ${doc.processingStatus})` }, - { status: 400 } - ) - } + if (doc.processingStatus !== 'processing') { + return NextResponse.json( + { error: `Document is not in processing state (current: ${doc.processingStatus})` }, + { status: 400 } + ) + } - if (!doc.processingStartedAt) { - return NextResponse.json( - { error: 'Document has no processing start time' }, - { status: 400 } - ) - } + if (!doc.processingStartedAt) { + return NextResponse.json( + { error: 'Document has no processing start time' }, + { status: 400 } + ) + } - try { - await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId) + try { + await markDocumentAsFailedTimeout(documentId, doc.processingStartedAt, requestId) + + return NextResponse.json({ + success: true, + data: { + documentId, + status: 'failed', + message: 'Document marked as failed due to timeout', + }, + }) + } catch (error) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error + } + } else if (validatedData.retryProcessing) { + const doc = accessCheck.document + + if (doc.processingStatus !== 'failed') { + return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 }) + } + + const docData = { + filename: doc.filename, + fileUrl: doc.fileUrl, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + } + + const result = await retryDocumentProcessing( + knowledgeBaseId, + documentId, + docData, + requestId + ) return NextResponse.json({ success: true, data: { documentId, - status: 'failed', - message: 'Document marked as failed due to timeout', + status: result.status, + message: result.message, }, }) - } catch (error) { - if (error instanceof Error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - throw error - } - } else if (validatedData.retryProcessing) { - const doc = accessCheck.document - - if (doc.processingStatus !== 'failed') { - return NextResponse.json({ error: 'Document is not in failed state' }, { status: 400 }) - } + } else { + const updatedDocument = await updateDocument(documentId, validatedData, requestId) - const docData = { - filename: doc.filename, - fileUrl: doc.fileUrl, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - } + logger.info( + `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` + ) - const result = await retryDocumentProcessing( - knowledgeBaseId, - documentId, - docData, - requestId - ) + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: validatedData.filename ?? accessCheck.document?.filename, + description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`, + request: req, + }) - return NextResponse.json({ - success: true, - data: { + return NextResponse.json({ + success: true, + data: updatedDocument, + }) + } + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid document update data`, { + errors: validationError.errors, documentId, - status: result.status, - message: result.message, - }, - }) - } else { - const updatedDocument = await updateDocument(documentId, validatedData, requestId) - - logger.info( - `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPDATED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: validatedData.filename ?? accessCheck.document?.filename, - description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`, - request: req, - }) - - return NextResponse.json({ - success: true, - data: updatedDocument, - }) - } - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid document update data`, { - errors: validationError.errors, - documentId, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError } - throw validationError + } catch (error) { + logger.error(`[${requestId}] Error updating document ${documentId}`, error) + return NextResponse.json({ error: 'Failed to update document' }, { status: 500 }) } - } catch (error) { - logger.error(`[${requestId}] Error updating document ${documentId}`, error) - return NextResponse.json({ error: 'Failed to update document' }, { status: 500 }) } -} - -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateRequestId() - const { id: knowledgeBaseId, documentId } = await params - - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized document delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId +) + +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params + + try { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized document delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) + const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${userId} attempted unauthorized document deletion: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${userId} attempted unauthorized document deletion: ${accessCheck.reason}` + + const result = await deleteDocument(documentId, requestId) + + logger.info( + `[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}` + ) + + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: accessCheck.document?.filename, + description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`, + metadata: { fileName: accessCheck.document?.filename }, + request: req, + }) + + const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? '' + captureServerEvent( + userId, + 'knowledge_base_document_deleted', + { knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId }, + kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const result = await deleteDocument(documentId, requestId) - - logger.info( - `[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}` - ) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: accessCheck.document?.filename, - description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`, - metadata: { fileName: accessCheck.document?.filename }, - request: req, - }) - - const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? '' - captureServerEvent( - userId, - 'knowledge_base_document_deleted', - { knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId }, - kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined - ) - - return NextResponse.json({ - success: true, - data: result, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting document`, error) - return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: result, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting document`, error) + return NextResponse.json({ error: 'Failed to delete document' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts index b60638907b4..2ee7abc4c35 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import { cleanupUnusedTagDefinitions, @@ -30,184 +31,192 @@ const BulkTagDefinitionsSchema = z.object({ }) // GET /api/knowledge/[id]/documents/[documentId]/tag-definitions - Get tag definitions for a document -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await params - - try { - logger.info(`[${requestId}] Getting tag definitions for document ${documentId}`) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, documentId } = await params + + try { + logger.info(`[${requestId}] Getting tag definitions for document ${documentId}`) - // Verify document exists and belongs to the knowledge base - const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify document exists and belongs to the knowledge base + const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id) + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${session.user.id} attempted unauthorized document access: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized document access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const tagDefinitions = await getDocumentTagDefinitions(knowledgeBaseId) + const tagDefinitions = await getDocumentTagDefinitions(knowledgeBaseId) - logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`) + logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`) - return NextResponse.json({ - success: true, - data: tagDefinitions, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting tag definitions`, error) - return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: tagDefinitions, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting tag definitions`, error) + return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) + } } -} +) // POST /api/knowledge/[id]/documents/[documentId]/tag-definitions - Create/update tag definitions -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await params - - try { - logger.info(`[${requestId}] Creating/updating tag definitions for document ${documentId}`) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, documentId } = await params - // Verify document exists and user has write access - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, session.user.id) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + try { + logger.info(`[${requestId}] Creating/updating tag definitions for document ${documentId}`) + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify document exists and user has write access + const accessCheck = await checkDocumentWriteAccess( + knowledgeBaseId, + documentId, + session.user.id + ) + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - let body - try { - body = await req.json() - } catch (error) { - logger.error(`[${requestId}] Failed to parse JSON body:`, error) - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) - } + let body + try { + body = await req.json() + } catch (error) { + logger.error(`[${requestId}] Failed to parse JSON body:`, error) + return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + } - if (!body || typeof body !== 'object') { - logger.error(`[${requestId}] Invalid request body:`, body) - return NextResponse.json( - { error: 'Request body must be a valid JSON object' }, - { status: 400 } - ) - } + if (!body || typeof body !== 'object') { + logger.error(`[${requestId}] Invalid request body:`, body) + return NextResponse.json( + { error: 'Request body must be a valid JSON object' }, + { status: 400 } + ) + } - const validatedData = BulkTagDefinitionsSchema.parse(body) + const validatedData = BulkTagDefinitionsSchema.parse(body) - const bulkData: BulkTagDefinitionsData = { - definitions: validatedData.definitions.map((def) => ({ - tagSlot: def.tagSlot, - displayName: def.displayName, - fieldType: def.fieldType, - originalDisplayName: def._originalDisplayName, - })), - } + const bulkData: BulkTagDefinitionsData = { + definitions: validatedData.definitions.map((def) => ({ + tagSlot: def.tagSlot, + displayName: def.displayName, + fieldType: def.fieldType, + originalDisplayName: def._originalDisplayName, + })), + } + + const result = await createOrUpdateTagDefinitionsBulk(knowledgeBaseId, bulkData, requestId) + + return NextResponse.json({ + success: true, + data: { + created: result.created, + updated: result.updated, + errors: result.errors, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } - const result = await createOrUpdateTagDefinitionsBulk(knowledgeBaseId, bulkData, requestId) - - return NextResponse.json({ - success: true, - data: { - created: result.created, - updated: result.updated, - errors: result.errors, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { + logger.error(`[${requestId}] Error creating/updating tag definitions`, error) return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } + { error: 'Failed to create/update tag definitions' }, + { status: 500 } ) } - - logger.error(`[${requestId}] Error creating/updating tag definitions`, error) - return NextResponse.json({ error: 'Failed to create/update tag definitions' }, { status: 500 }) } -} +) // DELETE /api/knowledge/[id]/documents/[documentId]/tag-definitions - Delete all tag definitions for a document -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string; documentId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, documentId } = await params - const { searchParams } = new URL(req.url) - const action = searchParams.get('action') // 'cleanup' or 'all' - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, documentId } = await params + const { searchParams } = new URL(req.url) + const action = searchParams.get('action') // 'cleanup' or 'all' + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Verify document exists and user has write access - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, session.user.id) - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + // Verify document exists and user has write access + const accessCheck = await checkDocumentWriteAccess( + knowledgeBaseId, + documentId, + session.user.id + ) + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } logger.warn( - `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + `[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}` ) - return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted unauthorized document write access: ${accessCheck.reason}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - if (action === 'cleanup') { - // Just run cleanup - logger.info(`[${requestId}] Running cleanup for KB ${knowledgeBaseId}`) - const cleanedUpCount = await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId) + if (action === 'cleanup') { + // Just run cleanup + logger.info(`[${requestId}] Running cleanup for KB ${knowledgeBaseId}`) + const cleanedUpCount = await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId) + + return NextResponse.json({ + success: true, + data: { cleanedUp: cleanedUpCount }, + }) + } + // Delete all tag definitions (original behavior) + logger.info(`[${requestId}] Deleting all tag definitions for KB ${knowledgeBaseId}`) + + const deletedCount = await deleteAllTagDefinitions(knowledgeBaseId, requestId) return NextResponse.json({ success: true, - data: { cleanedUp: cleanedUpCount }, + message: 'Tag definitions deleted successfully', + data: { deleted: deletedCount }, }) + } catch (error) { + logger.error(`[${requestId}] Error with tag definitions operation`, error) + return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 }) } - // Delete all tag definitions (original behavior) - logger.info(`[${requestId}] Deleting all tag definitions for KB ${knowledgeBaseId}`) - - const deletedCount = await deleteAllTagDefinitions(knowledgeBaseId, requestId) - - return NextResponse.json({ - success: true, - message: 'Tag definitions deleted successfully', - data: { deleted: deletedCount }, - }) - } catch (error) { - logger.error(`[${requestId}] Error with tag definitions operation`, error) - return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 83056e8f486..4aa9cbd4b9b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { bulkDocumentOperation, bulkDocumentOperationByFilter, @@ -65,411 +66,420 @@ const BulkUpdateDocumentsSchema = z message: 'Either selectAll must be true or documentIds must be provided', }) -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized documents access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized documents access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${session.user.id} attempted to access unauthorized knowledge base documents ${knowledgeBaseId}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted to access unauthorized knowledge base documents ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const url = new URL(req.url) - const enabledFilter = url.searchParams.get('enabledFilter') as - | 'all' - | 'enabled' - | 'disabled' - | null - const search = url.searchParams.get('search') || undefined - const limit = Number.parseInt(url.searchParams.get('limit') || '50') - const offset = Number.parseInt(url.searchParams.get('offset') || '0') - const sortByParam = url.searchParams.get('sortBy') - const sortOrderParam = url.searchParams.get('sortOrder') - - const validSortFields: DocumentSortField[] = [ - 'filename', - 'fileSize', - 'tokenCount', - 'chunkCount', - 'uploadedAt', - 'processingStatus', - 'enabled', - ] - const validSortOrders: SortOrder[] = ['asc', 'desc'] - - const sortBy = - sortByParam && validSortFields.includes(sortByParam as DocumentSortField) - ? (sortByParam as DocumentSortField) - : undefined - const sortOrder = - sortOrderParam && validSortOrders.includes(sortOrderParam as SortOrder) - ? (sortOrderParam as SortOrder) - : undefined - - let tagFilters: TagFilterCondition[] | undefined - const tagFiltersParam = url.searchParams.get('tagFilters') - if (tagFiltersParam) { - try { - const parsed = JSON.parse(tagFiltersParam) - if (Array.isArray(parsed)) { - tagFilters = parsed.filter( - (f: TagFilterCondition) => f.tagSlot && f.operator && f.value !== undefined - ) + const url = new URL(req.url) + const enabledFilter = url.searchParams.get('enabledFilter') as + | 'all' + | 'enabled' + | 'disabled' + | null + const search = url.searchParams.get('search') || undefined + const limit = Number.parseInt(url.searchParams.get('limit') || '50') + const offset = Number.parseInt(url.searchParams.get('offset') || '0') + const sortByParam = url.searchParams.get('sortBy') + const sortOrderParam = url.searchParams.get('sortOrder') + + const validSortFields: DocumentSortField[] = [ + 'filename', + 'fileSize', + 'tokenCount', + 'chunkCount', + 'uploadedAt', + 'processingStatus', + 'enabled', + ] + const validSortOrders: SortOrder[] = ['asc', 'desc'] + + const sortBy = + sortByParam && validSortFields.includes(sortByParam as DocumentSortField) + ? (sortByParam as DocumentSortField) + : undefined + const sortOrder = + sortOrderParam && validSortOrders.includes(sortOrderParam as SortOrder) + ? (sortOrderParam as SortOrder) + : undefined + + let tagFilters: TagFilterCondition[] | undefined + const tagFiltersParam = url.searchParams.get('tagFilters') + if (tagFiltersParam) { + try { + const parsed = JSON.parse(tagFiltersParam) + if (Array.isArray(parsed)) { + tagFilters = parsed.filter( + (f: TagFilterCondition) => f.tagSlot && f.operator && f.value !== undefined + ) + } + } catch { + logger.warn(`[${requestId}] Invalid tagFilters param`) } - } catch { - logger.warn(`[${requestId}] Invalid tagFilters param`) } - } - const result = await getDocuments( - knowledgeBaseId, - { - enabledFilter: enabledFilter || undefined, - search, - limit, - offset, - ...(sortBy && { sortBy }), - ...(sortOrder && { sortOrder }), - tagFilters, - }, - requestId - ) - - return NextResponse.json({ - success: true, - data: { - documents: result.documents, - pagination: result.pagination, - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching documents`, error) - return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 }) - } -} + const result = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter || undefined, + search, + limit, + offset, + ...(sortBy && { sortBy }), + ...(sortOrder && { sortOrder }), + tagFilters, + }, + requestId + ) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params + return NextResponse.json({ + success: true, + data: { + documents: result.documents, + pagination: result.pagination, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching documents`, error) + return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 }) + } + } +) - try { - const body = await req.json() - const { workflowId } = body +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params - logger.info(`[${requestId}] Knowledge base document creation request`, { - knowledgeBaseId, - workflowId, - hasWorkflowId: !!workflowId, - bodyKeys: Object.keys(body), - }) + try { + const body = await req.json() + const { workflowId } = body - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`, { + logger.info(`[${requestId}] Knowledge base document creation request`, { + knowledgeBaseId, workflowId, hasWorkflowId: !!workflowId, + bodyKeys: Object.keys(body), }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } - ) + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`, { + workflowId, + hasWorkflowId: !!workflowId, + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } + const userId = auth.userId - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + if (workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + if (!authorization.allowed) { + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status } + ) + } } - logger.warn( - `[${requestId}] User ${userId} attempted to create document in unauthorized knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId - - if (body.bulk === true) { - try { - const validatedData = BulkCreateDocumentsSchema.parse(body) + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) - const createdDocuments = await createDocumentRecords( - validatedData.documents, - knowledgeBaseId, - requestId + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted to create document in unauthorized knowledge base ${knowledgeBaseId}` ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info( - `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents` - ) + const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId + if (body.bulk === true) { try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ + const validatedData = BulkCreateDocumentsSchema.parse(body) + + const createdDocuments = await createDocumentRecords( + validatedData.documents, knowledgeBaseId, - documentsCount: createdDocuments.length, - uploadType: 'bulk', - recipe: validatedData.processingOptions?.recipe, - }) - } catch (_e) { - // Silently fail - } + requestId + ) - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: createdDocuments.length, - upload_type: 'bulk', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, + logger.info( + `[${requestId}] Starting controlled async processing of ${createdDocuments.length} documents` + ) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: createdDocuments.length, + uploadType: 'bulk', + recipe: validatedData.processingOptions?.recipe, + }) + } catch (_e) { + // Silently fail } - ) - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - validatedData.processingOptions ?? {}, - requestId - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) - }) + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId ?? '', + document_count: createdDocuments.length, + upload_type: 'bulk', + }, + { + ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: `${createdDocuments.length} document(s)`, - description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, - metadata: { - fileCount: createdDocuments.length, - fileNames: createdDocuments.map((doc) => doc.filename), - }, - request: req, - }) + processDocumentsWithQueue( + createdDocuments, + knowledgeBaseId, + validatedData.processingOptions ?? {}, + requestId + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) + }) - return NextResponse.json({ - success: true, - data: { - total: createdDocuments.length, - documentsCreated: createdDocuments.map((doc) => ({ - documentId: doc.documentId, - filename: doc.filename, - status: 'pending', - })), - processingMethod: 'background', - processingConfig: { - maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, - batchSize: getProcessingConfig().batchSize, - totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize), + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: `${createdDocuments.length} document(s)`, + description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, + metadata: { + fileCount: createdDocuments.length, + fileNames: createdDocuments.map((doc) => doc.filename), }, - }, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid bulk processing request data`, { - errors: validationError.errors, + request: req, }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } - } else { - try { - const validatedData = CreateDocumentSchema.parse(body) - - const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: validatedData.mimeType, - fileSize: validatedData.fileSize, + return NextResponse.json({ + success: true, + data: { + total: createdDocuments.length, + documentsCreated: createdDocuments.map((doc) => ({ + documentId: doc.documentId, + filename: doc.filename, + status: 'pending', + })), + processingMethod: 'background', + processingConfig: { + maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, + batchSize: getProcessingConfig().batchSize, + totalBatches: Math.ceil(createdDocuments.length / getProcessingConfig().batchSize), + }, + }, }) - } catch (_e) { - // Silently fail + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid bulk processing request data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError } - - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: kbWorkspaceId ?? '', - document_count: 1, - upload_type: 'single', - }, - { - ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), - setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } else { + try { + const validatedData = CreateDocumentSchema.parse(body) + + const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + mimeType: validatedData.mimeType, + fileSize: validatedData.fileSize, + }) + } catch (_e) { + // Silently fail } - ) - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: validatedData.filename, - description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`, - metadata: { - fileName: validatedData.filename, - fileType: validatedData.mimeType, - fileSize: validatedData.fileSize, - }, - request: req, - }) + captureServerEvent( + userId, + 'knowledge_base_document_uploaded', + { + knowledge_base_id: knowledgeBaseId, + workspace_id: kbWorkspaceId ?? '', + document_count: 1, + upload_type: 'single', + }, + { + ...(kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : {}), + setOnce: { first_document_uploaded_at: new Date().toISOString() }, + } + ) - return NextResponse.json({ - success: true, - data: newDocument, - }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid document data`, { - errors: validationError.errors, + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: validatedData.filename, + description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`, + metadata: { + fileName: validatedData.filename, + fileType: validatedData.mimeType, + fileSize: validatedData.fileSize, + }, + request: req, }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) + + return NextResponse.json({ + success: true, + data: newDocument, + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid document data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError } - throw validationError } - } - } catch (error) { - logger.error(`[${requestId}] Error creating document`, error) - - const errorMessage = error instanceof Error ? error.message : 'Failed to create document' - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') - const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' - - return NextResponse.json( - { error: errorMessage }, - { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } - ) - } -} + } catch (error) { + logger.error(`[${requestId}] Error creating document`, error) -export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params + const errorMessage = error instanceof Error ? error.message : 'Failed to create document' + const isStorageLimitError = + errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') + const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized bulk document operation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: errorMessage }, + { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } + ) } + } +) - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, session.user.id) +export const PATCH = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized bulk document operation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - logger.warn( - `[${requestId}] User ${session.user.id} attempted to perform bulk operation on unauthorized knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const body = await req.json() + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, session.user.id) - try { - const validatedData = BulkUpdateDocumentsSchema.parse(body) - const { operation, documentIds, selectAll, enabledFilter } = validatedData + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${session.user.id} attempted to perform bulk operation on unauthorized knowledge base ${knowledgeBaseId}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() try { - let result - if (selectAll) { - result = await bulkDocumentOperationByFilter( - knowledgeBaseId, - operation, - enabledFilter, - requestId - ) - } else if (documentIds && documentIds.length > 0) { - result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId) - } else { - return NextResponse.json({ error: 'No documents specified' }, { status: 400 }) - } + const validatedData = BulkUpdateDocumentsSchema.parse(body) + const { operation, documentIds, selectAll, enabledFilter } = validatedData - return NextResponse.json({ - success: true, - data: { - operation, - successCount: result.successCount, - updatedDocuments: result.updatedDocuments, - }, - }) - } catch (error) { - if (error instanceof Error && error.message === 'No valid documents found to update') { - return NextResponse.json({ error: 'No valid documents found to update' }, { status: 404 }) + try { + let result + if (selectAll) { + result = await bulkDocumentOperationByFilter( + knowledgeBaseId, + operation, + enabledFilter, + requestId + ) + } else if (documentIds && documentIds.length > 0) { + result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId) + } else { + return NextResponse.json({ error: 'No documents specified' }, { status: 400 }) + } + + return NextResponse.json({ + success: true, + data: { + operation, + successCount: result.successCount, + updatedDocuments: result.updatedDocuments, + }, + }) + } catch (error) { + if (error instanceof Error && error.message === 'No valid documents found to update') { + return NextResponse.json( + { error: 'No valid documents found to update' }, + { status: 404 } + ) + } + throw error } - throw error - } - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid bulk operation data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid bulk operation data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError } - throw validationError + } catch (error) { + logger.error(`[${requestId}] Error in bulk document operation`, error) + return NextResponse.json({ error: 'Failed to perform bulk operation' }, { status: 500 }) } - } catch (error) { - logger.error(`[${requestId}] Error in bulk document operation`, error) - return NextResponse.json({ error: 'Failed to perform bulk operation' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 59be57cd610..f88338a5b63 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentRecords, deleteDocument, @@ -34,213 +35,218 @@ const UpsertDocumentSchema = z.object({ workflowId: z.string().optional(), }) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params - try { - const body = await req.json() + try { + const body = await req.json() - logger.info(`[${requestId}] Knowledge base document upsert request`, { - knowledgeBaseId, - hasDocumentId: !!body.documentId, - filename: body.filename, - }) + logger.info(`[${requestId}] Knowledge base document upsert request`, { + knowledgeBaseId, + hasDocumentId: !!body.documentId, + filename: body.filename, + }) - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const validatedData = UpsertDocumentSchema.parse(body) + + if (validatedData.workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: validatedData.workflowId, + userId, + action: 'write', + }) + if (!authorization.allowed) { + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status } + ) + } + } - const validatedData = UpsertDocumentSchema.parse(body) + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) - if (validatedData.workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: validatedData.workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}` ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId) - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${knowledgeBaseId}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to upsert document in unauthorized knowledge base ${knowledgeBaseId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - let existingDocumentId: string | null = null - let isUpdate = false - - if (validatedData.documentId) { - const existingDoc = await db - .select({ id: document.id }) - .from(document) - .where( - and( - eq(document.id, validatedData.documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - isNull(document.deletedAt) + let existingDocumentId: string | null = null + let isUpdate = false + + if (validatedData.documentId) { + const existingDoc = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.id, validatedData.documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.deletedAt) + ) ) - ) - .limit(1) + .limit(1) + + if (existingDoc.length > 0) { + existingDocumentId = existingDoc[0].id + } + } else { + const docsByFilename = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.filename, validatedData.filename), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.deletedAt) + ) + ) + .limit(1) - if (existingDoc.length > 0) { - existingDocumentId = existingDoc[0].id + if (docsByFilename.length > 0) { + existingDocumentId = docsByFilename[0].id + } } - } else { - const docsByFilename = await db - .select({ id: document.id }) - .from(document) - .where( - and( - eq(document.filename, validatedData.filename), - eq(document.knowledgeBaseId, knowledgeBaseId), - isNull(document.deletedAt) - ) - ) - .limit(1) - if (docsByFilename.length > 0) { - existingDocumentId = docsByFilename[0].id + if (existingDocumentId) { + isUpdate = true + logger.info( + `[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old` + ) } - } - if (existingDocumentId) { - isUpdate = true - logger.info( - `[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old` + const createdDocuments = await createDocumentRecords( + [ + { + filename: validatedData.filename, + fileUrl: validatedData.fileUrl, + fileSize: validatedData.fileSize, + mimeType: validatedData.mimeType, + ...(validatedData.documentTagsData && { + documentTagsData: validatedData.documentTagsData, + }), + }, + ], + knowledgeBaseId, + requestId ) - } - - const createdDocuments = await createDocumentRecords( - [ - { - filename: validatedData.filename, - fileUrl: validatedData.fileUrl, - fileSize: validatedData.fileSize, - mimeType: validatedData.mimeType, - ...(validatedData.documentTagsData && { - documentTagsData: validatedData.documentTagsData, - }), - }, - ], - knowledgeBaseId, - requestId - ) - - const firstDocument = createdDocuments[0] - if (!firstDocument) { - logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`) - return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 }) - } - if (existingDocumentId) { - try { - await deleteDocument(existingDocumentId, requestId) - } catch (deleteError) { - logger.error( - `[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`, - deleteError - ) - await deleteDocument(firstDocument.documentId, requestId).catch(() => {}) - return NextResponse.json({ error: 'Failed to replace existing document' }, { status: 500 }) + const firstDocument = createdDocuments[0] + if (!firstDocument) { + logger.error(`[${requestId}] createDocumentRecords returned empty array unexpectedly`) + return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 }) } - } - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - validatedData.processingOptions ?? {}, - requestId - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) - }) + if (existingDocumentId) { + try { + await deleteDocument(existingDocumentId, requestId) + } catch (deleteError) { + logger.error( + `[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`, + deleteError + ) + await deleteDocument(firstDocument.documentId, requestId).catch(() => {}) + return NextResponse.json( + { error: 'Failed to replace existing document' }, + { status: 500 } + ) + } + } - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ + processDocumentsWithQueue( + createdDocuments, knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - recipe: validatedData.processingOptions?.recipe, + validatedData.processingOptions ?? {}, + requestId + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) }) - } catch (_e) { - // Silently fail - } - recordAudit({ - workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: knowledgeBaseId, - resourceName: validatedData.filename, - description: isUpdate - ? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"` - : `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`, - metadata: { - fileName: validatedData.filename, - previousDocumentId: existingDocumentId, - isUpdate, - }, - request: req, - }) + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + recipe: validatedData.processingOptions?.recipe, + }) + } catch (_e) { + // Silently fail + } - return NextResponse.json({ - success: true, - data: { - documentsCreated: [ - { - documentId: firstDocument.documentId, - filename: firstDocument.filename, - status: 'pending', + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: isUpdate ? AuditAction.DOCUMENT_UPDATED : AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: validatedData.filename, + description: isUpdate + ? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"` + : `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`, + metadata: { + fileName: validatedData.filename, + previousDocumentId: existingDocumentId, + isUpdate, + }, + request: req, + }) + + return NextResponse.json({ + success: true, + data: { + documentsCreated: [ + { + documentId: firstDocument.documentId, + filename: firstDocument.filename, + status: 'pending', + }, + ], + isUpdate, + previousDocumentId: existingDocumentId, + processingMethod: 'background', + processingConfig: { + maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, + batchSize: getProcessingConfig().batchSize, }, - ], - isUpdate, - previousDocumentId: existingDocumentId, - processingMethod: 'background', - processingConfig: { - maxConcurrentDocuments: getProcessingConfig().maxConcurrentDocuments, - batchSize: getProcessingConfig().batchSize, }, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid upsert request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } - logger.error(`[${requestId}] Error upserting document`, error) + logger.error(`[${requestId}] Error upserting document`, error) - const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document' - const isStorageLimitError = - errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') - const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' + const errorMessage = error instanceof Error ? error.message : 'Failed to upsert document' + const isStorageLimitError = + errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit') + const isMissingKnowledgeBase = errorMessage === 'Knowledge base not found' - return NextResponse.json( - { error: errorMessage }, - { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } - ) + return NextResponse.json( + { error: errorMessage }, + { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts index a2e5572f8bc..26eeb79f5ef 100644 --- a/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts +++ b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts @@ -2,68 +2,75 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getNextAvailableSlot, getTagDefinitions } from '@/lib/knowledge/tags/service' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' const logger = createLogger('NextAvailableSlotAPI') // GET /api/knowledge/[id]/next-available-slot - Get the next available tag slot for a knowledge base and field type -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params - const { searchParams } = new URL(req.url) - const fieldType = searchParams.get('fieldType') +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params + const { searchParams } = new URL(req.url) + const fieldType = searchParams.get('fieldType') - if (!fieldType) { - return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 }) - } - - try { - logger.info( - `[${requestId}] Getting next available slot for knowledge base ${knowledgeBaseId}, fieldType: ${fieldType}` - ) - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!fieldType) { + return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 }) } - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } + try { + logger.info( + `[${requestId}] Getting next available slot for knowledge base ${knowledgeBaseId}, fieldType: ${fieldType}` ) - } - // Get existing definitions once and reuse - const existingDefinitions = await getTagDefinitions(knowledgeBaseId) - const usedSlots = existingDefinitions - .filter((def) => def.fieldType === fieldType) - .map((def) => def.tagSlot) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Create a map for efficient lookup and pass to avoid redundant query - const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def])) - const nextAvailableSlot = await getNextAvailableSlot(knowledgeBaseId, fieldType, existingBySlot) + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + if (!accessCheck.hasAccess) { + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) + } - logger.info( - `[${requestId}] Next available slot for fieldType ${fieldType}: ${nextAvailableSlot}` - ) + // Get existing definitions once and reuse + const existingDefinitions = await getTagDefinitions(knowledgeBaseId) + const usedSlots = existingDefinitions + .filter((def) => def.fieldType === fieldType) + .map((def) => def.tagSlot) - const result = { - nextAvailableSlot, - fieldType, - usedSlots, - totalSlots: 7, - availableSlots: nextAvailableSlot ? 7 - usedSlots.length : 0, - } + // Create a map for efficient lookup and pass to avoid redundant query + const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def])) + const nextAvailableSlot = await getNextAvailableSlot( + knowledgeBaseId, + fieldType, + existingBySlot + ) + + logger.info( + `[${requestId}] Next available slot for fieldType ${fieldType}: ${nextAvailableSlot}` + ) - return NextResponse.json({ - success: true, - data: result, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting next available slot`, error) - return NextResponse.json({ error: 'Failed to get next available slot' }, { status: 500 }) + const result = { + nextAvailableSlot, + fieldType, + usedSlots, + totalSlots: 7, + availableSlots: nextAvailableSlot ? 7 - usedSlots.length : 0, + } + + return NextResponse.json({ + success: true, + data: result, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting next available slot`, error) + return NextResponse.json({ error: 'Failed to get next available slot' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 1d37f664ab7..33fbad8ed90 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -6,72 +6,75 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { KnowledgeBaseConflictError, restoreKnowledgeBase } from '@/lib/knowledge/service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreKnowledgeBaseAPI') -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [kb] = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId: knowledgeBase.workspaceId, - userId: knowledgeBase.userId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, id)) - .limit(1) + const [kb] = await db + .select({ + id: knowledgeBase.id, + name: knowledgeBase.name, + workspaceId: knowledgeBase.workspaceId, + userId: knowledgeBase.userId, + }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, id)) + .limit(1) - if (!kb) { - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } + if (!kb) { + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } - if (kb.workspaceId) { - const permission = await getUserEntityPermissions(auth.userId, 'workspace', kb.workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + if (kb.workspaceId) { + const permission = await getUserEntityPermissions(auth.userId, 'workspace', kb.workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + } else if (kb.userId !== auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } else if (kb.userId !== auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - await restoreKnowledgeBase(id, requestId) + await restoreKnowledgeBase(id, requestId) - logger.info(`[${requestId}] Restored knowledge base ${id}`) + logger.info(`[${requestId}] Restored knowledge base ${id}`) - recordAudit({ - workspaceId: kb.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_RESTORED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: kb.name, - description: `Restored knowledge base "${kb.name}"`, - request, - }) + recordAudit({ + workspaceId: kb.workspaceId, + actorId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.KNOWLEDGE_BASE_RESTORED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: kb.name, + description: `Restored knowledge base "${kb.name}"`, + request, + }) - return NextResponse.json({ success: true }) - } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } - logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) + logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 2dcf53701da..2a642589864 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteKnowledgeBase, getKnowledgeBaseById, @@ -51,190 +52,193 @@ const UpdateKnowledgeBaseSchema = z.object({ .optional(), }) -export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId + try { + const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const accessCheck = await checkKnowledgeBaseAccess(id, userId) - const accessCheck = await checkKnowledgeBaseAccess(id, userId) + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted to access unauthorized knowledge base ${id}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + const knowledgeBaseData = await getKnowledgeBaseById(id) + + if (!knowledgeBaseData) { return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) } - logger.warn( - `[${requestId}] User ${userId} attempted to access unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const knowledgeBaseData = await getKnowledgeBaseById(id) + logger.info(`[${requestId}] Retrieved knowledge base: ${id} for user ${userId}`) - if (!knowledgeBaseData) { - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + return NextResponse.json({ + success: true, + data: knowledgeBaseData, + }) + } catch (error) { + logger.error(`[${requestId}] Error fetching knowledge base`, error) + return NextResponse.json({ error: 'Failed to fetch knowledge base' }, { status: 500 }) } + } +) - logger.info(`[${requestId}] Retrieved knowledge base: ${id} for user ${userId}`) +export const PUT = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - return NextResponse.json({ - success: true, - data: knowledgeBaseData, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching knowledge base`, error) - return NextResponse.json({ error: 'Failed to fetch knowledge base' }, { status: 500 }) - } -} + try { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId -export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) + const body = await req.json() - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + try { + const validatedData = UpdateKnowledgeBaseSchema.parse(body) + + const updatedKnowledgeBase = await updateKnowledgeBase( + id, + { + name: validatedData.name, + description: validatedData.description, + workspaceId: validatedData.workspaceId, + chunkingConfig: validatedData.chunkingConfig, + }, + requestId + ) + + logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`) + + recordAudit({ + workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: validatedData.name ?? updatedKnowledgeBase.name, + description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`, + request: req, + }) + + return NextResponse.json({ + success: true, + data: updatedKnowledgeBase, + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid knowledge base update data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError } - logger.warn( - `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } catch (error) { + if (error instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + + logger.error(`[${requestId}] Error updating knowledge base`, error) + return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) } + } +) - const body = await req.json() +export const DELETE = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params try { - const validatedData = UpdateKnowledgeBaseSchema.parse(body) + const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) + + if (!accessCheck.hasAccess) { + if ('notFound' in accessCheck && accessCheck.notFound) { + logger.warn(`[${requestId}] Knowledge base not found: ${id}`) + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + await deleteKnowledgeBase(id, requestId) - const updatedKnowledgeBase = await updateKnowledgeBase( - id, - { - name: validatedData.name, - description: validatedData.description, - workspaceId: validatedData.workspaceId, - chunkingConfig: validatedData.chunkingConfig, - }, - requestId - ) + try { + PlatformEvents.knowledgeBaseDeleted({ + knowledgeBaseId: id, + }) + } catch { + // Telemetry should not fail the operation + } - logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`) + logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`) recordAudit({ workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, actorId: userId, actorName: auth.userName, actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, + action: AuditAction.KNOWLEDGE_BASE_DELETED, resourceType: AuditResourceType.KNOWLEDGE_BASE, resourceId: id, - resourceName: validatedData.name ?? updatedKnowledgeBase.name, - description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`, - request: req, + resourceName: accessCheck.knowledgeBase.name, + description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, + request: _request, }) return NextResponse.json({ success: true, - data: updatedKnowledgeBase, + data: { message: 'Knowledge base deleted successfully' }, }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid knowledge base update data`, { - errors: validationError.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } - ) - } - throw validationError - } - } catch (error) { - if (error instanceof KnowledgeBaseConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - - logger.error(`[${requestId}] Error updating knowledge base`, error) - return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) - } -} - -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } catch (error) { + logger.error(`[${requestId}] Error deleting knowledge base`, error) + return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 }) } - const userId = auth.userId - - const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - - if (!accessCheck.hasAccess) { - if ('notFound' in accessCheck && accessCheck.notFound) { - logger.warn(`[${requestId}] Knowledge base not found: ${id}`) - return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) - } - logger.warn( - `[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - await deleteKnowledgeBase(id, requestId) - - try { - PlatformEvents.knowledgeBaseDeleted({ - knowledgeBaseId: id, - }) - } catch { - // Telemetry should not fail the operation - } - - logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`) - - recordAudit({ - workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: accessCheck.knowledgeBase.name, - description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, - request: _request, - }) - - return NextResponse.json({ - success: true, - data: { message: 'Knowledge base deleted successfully' }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting knowledge base`, error) - return NextResponse.json({ error: 'Failed to delete knowledge base' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts index bb6f8d9b46e..24ad5c5a0a2 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTagDefinition } from '@/lib/knowledge/tags/service' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -10,39 +11,38 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TagDefinitionAPI') // DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string; tagId: string }> } -) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId, tagId } = await params - - try { - logger.info( - `[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}` - ) - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; tagId: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId, tagId } = await params + + try { + logger.info( + `[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}` ) - } - const deletedTag = await deleteTagDefinition(knowledgeBaseId, tagId, requestId) - - return NextResponse.json({ - success: true, - message: `Tag definition "${deletedTag.displayName}" deleted successfully`, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting tag definition`, error) - return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 }) + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!accessCheck.hasAccess) { + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) + } + + const deletedTag = await deleteTagDefinition(knowledgeBaseId, tagId, requestId) + + return NextResponse.json({ + success: true, + message: `Tag definition "${deletedTag.displayName}" deleted successfully`, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting tag definition`, error) + return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts index 57ad6c9fb2f..bd566317725 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -12,108 +13,112 @@ export const dynamic = 'force-dynamic' const logger = createLogger('KnowledgeBaseTagDefinitionsAPI') // GET /api/knowledge/[id]/tag-definitions - Get all tag definitions for a knowledge base -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params - try { - logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`) + try { + logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`) - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } - // For session auth, verify KB access. Internal JWT is trusted. - if (auth.authType === AuthType.SESSION && auth.userId) { - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) + // For session auth, verify KB access. Internal JWT is trusted. + if (auth.authType === AuthType.SESSION && auth.userId) { + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!accessCheck.hasAccess) { + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) + } } - } - const tagDefinitions = await getTagDefinitions(knowledgeBaseId) + const tagDefinitions = await getTagDefinitions(knowledgeBaseId) - logger.info( - `[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})` - ) + logger.info( + `[${requestId}] Retrieved ${tagDefinitions.length} tag definitions (${auth.authType})` + ) - return NextResponse.json({ - success: true, - data: tagDefinitions, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting tag definitions`, error) - return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: tagDefinitions, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting tag definitions`, error) + return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) + } } -} +) // POST /api/knowledge/[id]/tag-definitions - Create a new tag definition -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params - try { - logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`) - - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } + try { + logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`) - // For session auth, verify KB access. Internal JWT is trusted. - if (auth.authType === AuthType.SESSION && auth.userId) { - const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) } - } - const body = await req.json() + // For session auth, verify KB access. Internal JWT is trusted. + if (auth.authType === AuthType.SESSION && auth.userId) { + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) + if (!accessCheck.hasAccess) { + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) + } + } - const CreateTagDefinitionSchema = z.object({ - tagSlot: z.string().min(1, 'Tag slot is required'), - displayName: z.string().min(1, 'Display name is required'), - fieldType: z.enum(SUPPORTED_FIELD_TYPES as [string, ...string[]], { - errorMap: () => ({ message: 'Invalid field type' }), - }), - }) + const body = await req.json() + + const CreateTagDefinitionSchema = z.object({ + tagSlot: z.string().min(1, 'Tag slot is required'), + displayName: z.string().min(1, 'Display name is required'), + fieldType: z.enum(SUPPORTED_FIELD_TYPES as [string, ...string[]], { + errorMap: () => ({ message: 'Invalid field type' }), + }), + }) + + let validatedData + try { + validatedData = CreateTagDefinitionSchema.parse(body) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + throw error + } - let validatedData - try { - validatedData = CreateTagDefinitionSchema.parse(body) + const newTagDefinition = await createTagDefinition( + { + knowledgeBaseId, + tagSlot: validatedData.tagSlot, + displayName: validatedData.displayName, + fieldType: validatedData.fieldType, + }, + requestId + ) + + return NextResponse.json({ + success: true, + data: newTagDefinition, + }) } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - throw error + logger.error(`[${requestId}] Error creating tag definition`, error) + return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 }) } - - const newTagDefinition = await createTagDefinition( - { - knowledgeBaseId, - tagSlot: validatedData.tagSlot, - displayName: validatedData.displayName, - fieldType: validatedData.fieldType, - }, - requestId - ) - - return NextResponse.json({ - success: true, - data: newTagDefinition, - }) - } catch (error) { - logger.error(`[${requestId}] Error creating tag definition`, error) - return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts index 3ba49402445..9785603f4d5 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getTagUsage } from '@/lib/knowledge/tags/service' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' @@ -10,38 +11,42 @@ export const dynamic = 'force-dynamic' const logger = createLogger('TagUsageAPI') // GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) - const { id: knowledgeBaseId } = await params +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) + const { id: knowledgeBaseId } = await params + + try { + logger.info( + `[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}` + ) - try { - logger.info(`[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}`) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + if (!accessCheck.hasAccess) { + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) + } - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) - if (!accessCheck.hasAccess) { - return NextResponse.json( - { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, - { status: accessCheck.notFound ? 404 : 403 } - ) - } + const usageStats = await getTagUsage(knowledgeBaseId, requestId) - const usageStats = await getTagUsage(knowledgeBaseId, requestId) - - logger.info( - `[${requestId}] Retrieved usage statistics for ${usageStats.length} tag definitions` - ) + logger.info( + `[${requestId}] Retrieved usage statistics for ${usageStats.length} tag definitions` + ) - return NextResponse.json({ - success: true, - data: usageStats, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting tag usage statistics`, error) - return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: usageStats, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting tag usage statistics`, error) + return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index df356bc3222..133d553388f 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -5,6 +5,7 @@ import { and, eq, inArray, isNull, lte } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const logger = createLogger('ConnectorSyncSchedulerAPI') * Cron endpoint that checks for connectors due for sync and dispatches sync jobs. * Should be called every 5 minutes by an external cron service. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Connector sync scheduler triggered`) @@ -96,4 +97,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Connector sync scheduler error`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 31951276176..fd7085941d8 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createKnowledgeBase, getKnowledgeBases, @@ -55,7 +56,7 @@ const CreateKnowledgeBaseSchema = z.object({ ), }) -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -82,9 +83,9 @@ export async function GET(req: NextRequest) { logger.error(`[${requestId}] Error fetching knowledge bases`, error) return NextResponse.json({ error: 'Failed to fetch knowledge bases' }, { status: 500 }) } -} +}) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -172,4 +173,4 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error creating knowledge base`, error) return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 348a60ec71d..2bb7739948c 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' @@ -70,7 +71,7 @@ const VectorSearchSchema = z } ) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -449,4 +450,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 494c2504157..639132abdd9 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -11,169 +11,172 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('LogDetailsByIdAPI') export const revalidate = 0 -export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized log details access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const { id } = await params - - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - deploymentVersionId: workflowExecutionLogs.deploymentVersionId, - level: workflowExecutionLogs.level, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - deploymentVersion: workflowDeploymentVersion.version, - deploymentVersionName: workflowDeploymentVersion.name, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .leftJoin( - workflowDeploymentVersion, - eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) - ) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized log details access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const log = rows[0] + const userId = session.user.id + const { id } = await params - // Fallback: check job_execution_logs - if (!log) { - const jobRows = await db + const rows = await db .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - level: jobExecutionLogs.level, - status: jobExecutionLogs.status, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - executionData: jobExecutionLogs.executionData, - cost: jobExecutionLogs.cost, - createdAt: jobExecutionLogs.createdAt, + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + deploymentVersionId: workflowExecutionLogs.deploymentVersionId, + level: workflowExecutionLogs.level, + status: workflowExecutionLogs.status, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + deploymentVersion: workflowDeploymentVersion.version, + deploymentVersionName: workflowDeploymentVersion.name, }) - .from(jobExecutionLogs) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId) + ) .innerJoin( permissions, and( eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.userId, userId) ) ) - .where(eq(jobExecutionLogs.id, id)) + .where(eq(workflowExecutionLogs.id, id)) .limit(1) - const jobLog = jobRows[0] - if (!jobLog) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const log = rows[0] + + // Fallback: check job_execution_logs + if (!log) { + const jobRows = await db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + level: jobExecutionLogs.level, + status: jobExecutionLogs.status, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + executionData: jobExecutionLogs.executionData, + cost: jobExecutionLogs.cost, + createdAt: jobExecutionLogs.createdAt, + }) + .from(jobExecutionLogs) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, jobExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(eq(jobExecutionLogs.id, id)) + .limit(1) + + const jobLog = jobRows[0] + if (!jobLog) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const execData = jobLog.executionData as Record | null + const response = { + id: jobLog.id, + workflowId: null, + executionId: jobLog.executionId, + deploymentVersionId: null, + deploymentVersion: null, + deploymentVersionName: null, + level: jobLog.level, + status: jobLog.status, + duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, + trigger: jobLog.trigger, + createdAt: jobLog.startedAt.toISOString(), + workflow: null, + jobTitle: (execData?.trigger?.source as string) || null, + executionData: { + totalDuration: jobLog.totalDurationMs, + ...execData, + enhanced: true, + }, + cost: jobLog.cost as any, + } + + return NextResponse.json({ data: response }) } - const execData = jobLog.executionData as Record | null + const workflowSummary = log.workflowId + ? { + id: log.workflowId, + name: log.workflowName, + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt, + updatedAt: log.workflowUpdatedAt, + } + : null + const response = { - id: jobLog.id, - workflowId: null, - executionId: jobLog.executionId, - deploymentVersionId: null, - deploymentVersion: null, - deploymentVersionName: null, - level: jobLog.level, - status: jobLog.status, - duration: jobLog.totalDurationMs ? `${jobLog.totalDurationMs}ms` : null, - trigger: jobLog.trigger, - createdAt: jobLog.startedAt.toISOString(), - workflow: null, - jobTitle: (execData?.trigger?.source as string) || null, + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + deploymentVersionId: log.deploymentVersionId, + deploymentVersion: log.deploymentVersion ?? null, + deploymentVersionName: log.deploymentVersionName ?? null, + level: log.level, + status: log.status, + duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, + trigger: log.trigger, + createdAt: log.startedAt.toISOString(), + files: log.files || undefined, + workflow: workflowSummary, executionData: { - totalDuration: jobLog.totalDurationMs, - ...execData, + totalDuration: log.totalDurationMs, + ...(log.executionData as any), enhanced: true, }, - cost: jobLog.cost as any, + cost: log.cost as any, } return NextResponse.json({ data: response }) + } catch (error: any) { + logger.error(`[${requestId}] log details fetch error`, error) + return NextResponse.json({ error: error.message }, { status: 500 }) } - - const workflowSummary = log.workflowId - ? { - id: log.workflowId, - name: log.workflowName, - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt, - updatedAt: log.workflowUpdatedAt, - } - : null - - const response = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - deploymentVersionId: log.deploymentVersionId, - deploymentVersion: log.deploymentVersion ?? null, - deploymentVersionName: log.deploymentVersionName ?? null, - level: log.level, - status: log.status, - duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null, - trigger: log.trigger, - createdAt: log.startedAt.toISOString(), - files: log.files || undefined, - workflow: workflowSummary, - executionData: { - totalDuration: log.totalDurationMs, - ...(log.executionData as any), - enhanced: true, - }, - cost: log.cost as any, - } - - return NextResponse.json({ data: response }) - } catch (error: any) { - logger.error(`[${requestId}] log details fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/logs/cleanup/route.ts b/apps/sim/app/api/logs/cleanup/route.ts index 25a0acabf55..cab8b95a358 100644 --- a/apps/sim/app/api/logs/cleanup/route.ts +++ b/apps/sim/app/api/logs/cleanup/route.ts @@ -7,6 +7,7 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { sqlIsPaid } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' @@ -16,7 +17,7 @@ const logger = createLogger('LogsCleanupAPI') const BATCH_SIZE = 2000 -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const authError = verifyCronAuth(request, 'logs cleanup') if (authError) { @@ -217,4 +218,4 @@ export async function GET(request: NextRequest) { logger.error('Error in log cleanup process:', { error }) return NextResponse.json({ error: 'Failed to process log cleanup' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/logs/execution/[executionId]/route.ts b/apps/sim/app/api/logs/execution/[executionId]/route.ts index 4e6495b4df9..41ba9c7776d 100644 --- a/apps/sim/app/api/logs/execution/[executionId]/route.ts +++ b/apps/sim/app/api/logs/execution/[executionId]/route.ts @@ -11,160 +11,165 @@ import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' const logger = createLogger('LogsByExecutionIdAPI') -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ executionId: string }> } -) { - const requestId = generateRequestId() - - try { - const { executionId } = await params - - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized execution data access attempt for: ${executionId}`) - return NextResponse.json( - { error: authResult.error || 'Authentication required' }, - { status: 401 } - ) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ executionId: string }> }) => { + const requestId = generateRequestId() + + try { + const { executionId } = await params - const authenticatedUserId = authResult.userId - - const [workflowLog] = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - cost: workflowExecutionLogs.cost, - executionData: workflowExecutionLogs.executionData, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, authenticatedUserId) + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized execution data access attempt for: ${executionId}`) + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } ) - ) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) + } - // Fallback: check job_execution_logs - if (!workflowLog) { - const [jobLog] = await db + const authenticatedUserId = authResult.userId + + const [workflowLog] = await db .select({ - id: jobExecutionLogs.id, - executionId: jobExecutionLogs.executionId, - trigger: jobExecutionLogs.trigger, - startedAt: jobExecutionLogs.startedAt, - endedAt: jobExecutionLogs.endedAt, - totalDurationMs: jobExecutionLogs.totalDurationMs, - cost: jobExecutionLogs.cost, - executionData: jobExecutionLogs.executionData, + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + cost: workflowExecutionLogs.cost, + executionData: workflowExecutionLogs.executionData, }) - .from(jobExecutionLogs) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin( permissions, and( eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, jobExecutionLogs.workspaceId), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.userId, authenticatedUserId) ) ) - .where(eq(jobExecutionLogs.executionId, executionId)) + .where(eq(workflowExecutionLogs.executionId, executionId)) .limit(1) - if (!jobLog) { - logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`) - return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + // Fallback: check job_execution_logs + if (!workflowLog) { + const [jobLog] = await db + .select({ + id: jobExecutionLogs.id, + executionId: jobExecutionLogs.executionId, + trigger: jobExecutionLogs.trigger, + startedAt: jobExecutionLogs.startedAt, + endedAt: jobExecutionLogs.endedAt, + totalDurationMs: jobExecutionLogs.totalDurationMs, + cost: jobExecutionLogs.cost, + executionData: jobExecutionLogs.executionData, + }) + .from(jobExecutionLogs) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, jobExecutionLogs.workspaceId), + eq(permissions.userId, authenticatedUserId) + ) + ) + .where(eq(jobExecutionLogs.executionId, executionId)) + .limit(1) + + if (!jobLog) { + logger.warn(`[${requestId}] Execution not found or access denied: ${executionId}`) + return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + } + + return NextResponse.json({ + executionId, + workflowId: null, + workflowState: null, + childWorkflowSnapshots: {}, + executionMetadata: { + trigger: jobLog.trigger, + startedAt: jobLog.startedAt.toISOString(), + endedAt: jobLog.endedAt?.toISOString(), + totalDurationMs: jobLog.totalDurationMs, + cost: jobLog.cost || null, + }, + }) } - return NextResponse.json({ - executionId, - workflowId: null, - workflowState: null, - childWorkflowSnapshots: {}, - executionMetadata: { - trigger: jobLog.trigger, - startedAt: jobLog.startedAt.toISOString(), - endedAt: jobLog.endedAt?.toISOString(), - totalDurationMs: jobLog.totalDurationMs, - cost: jobLog.cost || null, - }, - }) - } + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) + + if (!snapshot) { + logger.warn( + `[${requestId}] Workflow state snapshot not found for execution: ${executionId}` + ) + return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) + } - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) - .limit(1) + const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData'] + const traceSpans = (executionData?.traceSpans as TraceSpan[]) || [] + const childSnapshotIds = new Set() + const collectSnapshotIds = (spans: TraceSpan[]) => { + spans.forEach((span) => { + const snapshotId = span.childWorkflowSnapshotId + if (typeof snapshotId === 'string') { + childSnapshotIds.add(snapshotId) + } + if (span.children?.length) { + collectSnapshotIds(span.children) + } + }) + } + if (traceSpans.length > 0) { + collectSnapshotIds(traceSpans) + } - if (!snapshot) { - logger.warn(`[${requestId}] Workflow state snapshot not found for execution: ${executionId}`) - return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) - } + const childWorkflowSnapshots = + childSnapshotIds.size > 0 + ? await db + .select() + .from(workflowExecutionSnapshots) + .where(inArray(workflowExecutionSnapshots.id, Array.from(childSnapshotIds))) + : [] + + const childSnapshotMap = childWorkflowSnapshots.reduce>( + (acc, snap) => { + acc[snap.id] = snap.stateData + return acc + }, + {} + ) - const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData'] - const traceSpans = (executionData?.traceSpans as TraceSpan[]) || [] - const childSnapshotIds = new Set() - const collectSnapshotIds = (spans: TraceSpan[]) => { - spans.forEach((span) => { - const snapshotId = span.childWorkflowSnapshotId - if (typeof snapshotId === 'string') { - childSnapshotIds.add(snapshotId) - } - if (span.children?.length) { - collectSnapshotIds(span.children) - } - }) - } - if (traceSpans.length > 0) { - collectSnapshotIds(traceSpans) - } + const response = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + childWorkflowSnapshots: childSnapshotMap, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt?.toISOString(), + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.cost || null, + }, + } - const childWorkflowSnapshots = - childSnapshotIds.size > 0 - ? await db - .select() - .from(workflowExecutionSnapshots) - .where(inArray(workflowExecutionSnapshots.id, Array.from(childSnapshotIds))) - : [] - - const childSnapshotMap = childWorkflowSnapshots.reduce>((acc, snap) => { - acc[snap.id] = snap.stateData - return acc - }, {}) - - const response = { - executionId, - workflowId: workflowLog.workflowId, - workflowState: snapshot.stateData, - childWorkflowSnapshots: childSnapshotMap, - executionMetadata: { - trigger: workflowLog.trigger, - startedAt: workflowLog.startedAt.toISOString(), - endedAt: workflowLog.endedAt?.toISOString(), - totalDurationMs: workflowLog.totalDurationMs, - cost: workflowLog.cost || null, - }, + return NextResponse.json(response) + } catch (error) { + logger.error(`[${requestId}] Error fetching execution data:`, error) + return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 }) } - - return NextResponse.json(response) - } catch (error) { - logger.error(`[${requestId}] Error fetching execution data:`, error) - return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts index ef32904a301..b814678caf6 100644 --- a/apps/sim/app/api/logs/export/route.ts +++ b/apps/sim/app/api/logs/export/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' const logger = createLogger('LogsExportAPI') @@ -19,7 +20,7 @@ function escapeCsv(value: any): string { return str } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -147,4 +148,4 @@ export async function GET(request: NextRequest) { logger.error('Export error', { error: error?.message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index f6f631415fb..6c34126a9ec 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -28,6 +28,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' const logger = createLogger('LogsAPI') @@ -40,7 +41,7 @@ const QueryParamsSchema = LogFilterParamsSchema.extend({ offset: z.coerce.number().optional().default(0), }) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -606,4 +607,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] logs fetch error`, error) return NextResponse.json({ error: error.message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 030b444b6cf..776982855e6 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters' const logger = createLogger('LogsStatsAPI') @@ -45,7 +46,7 @@ export interface DashboardStatsResponse { segmentMs: number } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -294,4 +295,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] logs stats fetch error`, error) return NextResponse.json({ error: error.message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/logs/triggers/route.ts b/apps/sim/app/api/logs/triggers/route.ts index dfbcd1001c7..b1f42fb507f 100644 --- a/apps/sim/app/api/logs/triggers/route.ts +++ b/apps/sim/app/api/logs/triggers/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TriggersAPI') @@ -21,7 +22,7 @@ const QueryParamsSchema = z.object({ * Returns unique trigger types from workflow execution logs * Only includes integration triggers (excludes core types: api, manual, webhook, chat, schedule) */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -82,4 +83,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Failed to fetch triggers`, { error: err }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index f1377d5ad13..94948d56dbf 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -30,6 +30,7 @@ import { env } from '@/lib/core/config/env' import { RateLimiter } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission, resolveWorkflowIdForUser, @@ -529,14 +530,14 @@ async function handleMcpRequestWithSdk( } } -export async function GET() { +export const GET = withRouteHandler(async () => { // Return 405 to signal that server-initiated SSE notifications are not // supported. Without this, clients like mcp-remote will repeatedly // reconnect trying to open an SSE stream, flooding the logs with GETs. return new NextResponse(null, { status: 405 }) -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key') if (!hasAuth) { @@ -569,9 +570,9 @@ export async function POST(request: NextRequest) { status: 500, }) } -} +}) -export async function OPTIONS() { +export const OPTIONS = withRouteHandler(async () => { return new NextResponse(null, { status: 204, headers: { @@ -582,12 +583,12 @@ export async function OPTIONS() { 'Access-Control-Max-Age': '86400', }, }) -} +}) -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { void request return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 }) -} +}) /** * Increment MCP copilot call counter in userStats (fire-and-forget). diff --git a/apps/sim/app/api/mcp/discover/route.ts b/apps/sim/app/api/mcp/discover/route.ts index c386c304cc7..5c63714b0a0 100644 --- a/apps/sim/app/api/mcp/discover/route.ts +++ b/apps/sim/app/api/mcp/discover/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('McpDiscoverAPI') @@ -13,7 +14,7 @@ export const dynamic = 'force-dynamic' /** * Discover all MCP servers available to the authenticated user. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkHybridAuth(request, { requireWorkflowId: false }) @@ -111,4 +112,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/mcp/events/route.ts b/apps/sim/app/api/mcp/events/route.ts index 61c0f4c82a0..8f4ed93d0c9 100644 --- a/apps/sim/app/api/mcp/events/route.ts +++ b/apps/sim/app/api/mcp/events/route.ts @@ -8,40 +8,43 @@ * Auth is handled via session cookies (EventSource sends cookies automatically). */ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' import { mcpPubSub } from '@/lib/mcp/pubsub' export const dynamic = 'force-dynamic' -export const GET = createWorkspaceSSE({ - label: 'mcp-events', - subscriptions: [ - { - subscribe: (workspaceId, send) => { - if (!mcpConnectionManager) return () => {} - return mcpConnectionManager.subscribe((event) => { - if (event.workspaceId !== workspaceId) return - send('tools_changed', { - source: 'external', - serverId: event.serverId, - timestamp: event.timestamp, +export const GET = withRouteHandler( + createWorkspaceSSE({ + label: 'mcp-events', + subscriptions: [ + { + subscribe: (workspaceId, send) => { + if (!mcpConnectionManager) return () => {} + return mcpConnectionManager.subscribe((event) => { + if (event.workspaceId !== workspaceId) return + send('tools_changed', { + source: 'external', + serverId: event.serverId, + timestamp: event.timestamp, + }) }) - }) + }, }, - }, - { - subscribe: (workspaceId, send) => { - if (!mcpPubSub) return () => {} - return mcpPubSub.onWorkflowToolsChanged((event) => { - if (event.workspaceId !== workspaceId) return - send('tools_changed', { - source: 'workflow', - serverId: event.serverId, - timestamp: Date.now(), + { + subscribe: (workspaceId, send) => { + if (!mcpPubSub) return () => {} + return mcpPubSub.onWorkflowToolsChanged((event) => { + if (event.workspaceId !== workspaceId) return + send('tools_changed', { + source: 'workflow', + serverId: event.serverId, + timestamp: Date.now(), + }) }) - }) + }, }, - }, - ], -}) + ], + }) +) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 0be8778bc53..dbb9a0bf916 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -23,6 +23,7 @@ import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -79,143 +80,147 @@ async function getServer(serverId: string) { return server } -export async function GET(request: NextRequest, { params }: { params: Promise }) { - const { serverId } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { serverId } = await params - try { - const server = await getServer(serverId) - if (!server) { - return NextResponse.json({ error: 'Server not found' }, { status: 404 }) - } - - if (!server.isPublic) { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + try { + const server = await getServer(serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } - const workspacePermission = await getUserEntityPermissions( - auth.userId, - 'workspace', - server.workspaceId - ) - if (workspacePermission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - - return NextResponse.json({ - name: server.name, - version: '1.0.0', - protocolVersion: '2024-11-05', - capabilities: { tools: {} }, - }) - } catch (error) { - logger.error('Error getting MCP server info:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function POST(request: NextRequest, { params }: { params: Promise }) { - const { serverId } = await params + if (!server.isPublic) { + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - try { - const server = await getServer(serverId) - if (!server) { - return NextResponse.json({ error: 'Server not found' }, { status: 404 }) - } + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - let executeAuthContext: ExecuteAuthContext | null = null - if (!server.isPublic) { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const workspacePermission = await getUserEntityPermissions( + auth.userId, + 'workspace', + server.workspaceId + ) + if (workspacePermission === null) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } } - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + return NextResponse.json({ + name: server.name, + version: '1.0.0', + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + }) + } catch (error) { + logger.error('Error getting MCP server info:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + } +) - const workspacePermission = await getUserEntityPermissions( - auth.userId, - 'workspace', - server.workspaceId - ) - if (workspacePermission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { serverId } = await params - executeAuthContext = { - authType: auth.authType, - userId: auth.userId, - apiKey: auth.authType === AuthType.API_KEY ? request.headers.get('X-API-Key') : null, + try { + const server = await getServer(serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) } - } - const body = await request.json() - const message = body as JSONRPCMessage - - if (isJSONRPCNotification(message)) { - logger.info(`Received notification: ${message.method}`) - return new NextResponse(null, { status: 202 }) - } + let executeAuthContext: ExecuteAuthContext | null = null + if (!server.isPublic) { + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!isJSONRPCRequest(message)) { - return NextResponse.json( - createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), - { - status: 400, + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== server.workspaceId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - ) - } - const { id, method, params: rpcParams } = message + const workspacePermission = await getUserEntityPermissions( + auth.userId, + 'workspace', + server.workspaceId + ) + if (workspacePermission === null) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - switch (method) { - case 'initialize': { - const result: InitializeResult = { - protocolVersion: '2024-11-05', - capabilities: { tools: {} }, - serverInfo: { name: server.name, version: '1.0.0' }, + executeAuthContext = { + authType: auth.authType, + userId: auth.userId, + apiKey: auth.authType === AuthType.API_KEY ? request.headers.get('X-API-Key') : null, } - return NextResponse.json(createResponse(id, result)) } - case 'ping': - return NextResponse.json(createResponse(id, {})) - - case 'tools/list': - return handleToolsList(id, serverId) + const body = await request.json() + const message = body as JSONRPCMessage - case 'tools/call': - return handleToolsCall( - id, - serverId, - rpcParams as { name: string; arguments?: Record }, - executeAuthContext, - server.isPublic ? server.createdBy : undefined, - request.headers.get(SIM_VIA_HEADER) - ) + if (isJSONRPCNotification(message)) { + logger.info(`Received notification: ${message.method}`) + return new NextResponse(null, { status: 202 }) + } - default: + if (!isJSONRPCRequest(message)) { return NextResponse.json( - createError(id, ErrorCode.MethodNotFound, `Method not found: ${method}`), + createError(0, ErrorCode.InvalidRequest, 'Invalid JSON-RPC message'), { - status: 404, + status: 400, } ) + } + + const { id, method, params: rpcParams } = message + + switch (method) { + case 'initialize': { + const result: InitializeResult = { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: server.name, version: '1.0.0' }, + } + return NextResponse.json(createResponse(id, result)) + } + + case 'ping': + return NextResponse.json(createResponse(id, {})) + + case 'tools/list': + return handleToolsList(id, serverId) + + case 'tools/call': + return handleToolsCall( + id, + serverId, + rpcParams as { name: string; arguments?: Record }, + executeAuthContext, + server.isPublic ? server.createdBy : undefined, + request.headers.get(SIM_VIA_HEADER) + ) + + default: + return NextResponse.json( + createError(id, ErrorCode.MethodNotFound, `Method not found: ${method}`), + { + status: 404, + } + ) + } + } catch (error) { + logger.error('Error handling MCP request:', error) + return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), { + status: 500, + }) } - } catch (error) { - logger.error('Error handling MCP request:', error) - return NextResponse.json(createError(0, ErrorCode.InternalError, 'Internal error'), { - status: 500, - }) } -} +) async function handleToolsList(id: RequestId, serverId: string): Promise { try { @@ -366,35 +371,37 @@ async function handleToolsCall( } } -export async function DELETE(request: NextRequest, { params }: { params: Promise }) { - const { serverId } = await params +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise }) => { + const { serverId } = await params - try { - const server = await getServer(serverId) - if (!server) { - return NextResponse.json({ error: 'Server not found' }, { status: 404 }) - } + try { + const server = await getServer(serverId) + if (!server) { + return NextResponse.json({ error: 'Server not found' }, { status: 404 }) + } - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!server.isPublic) { - const workspacePermission = await getUserEntityPermissions( - auth.userId, - 'workspace', - server.workspaceId - ) - if (workspacePermission === null) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + if (!server.isPublic) { + const workspacePermission = await getUserEntityPermissions( + auth.userId, + 'workspace', + server.workspaceId + ) + if (workspacePermission === null) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } } - } - logger.info(`MCP session terminated for server ${serverId}`) - return new NextResponse(null, { status: 204 }) - } catch (error) { - logger.error('Error handling MCP DELETE request:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.info(`MCP session terminated for server ${serverId}`) + return new NextResponse(null, { status: 204 }) + } catch (error) { + logger.error('Error handling MCP DELETE request:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts index b6b186ec4ac..c8074fce951 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -3,6 +3,7 @@ import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import type { McpServerStatusConfig, McpTool, McpToolSchema } from '@/lib/mcp/types' @@ -153,8 +154,9 @@ async function syncToolSchemasToWorkflows( } } -export const POST = withMcpAuth<{ id: string }>('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { +export const POST = + withRouteHandler(withMcpAuth < { id: string }) > + 'read'(async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { const { id: serverId } = await params try { @@ -255,5 +257,4 @@ export const POST = withMcpAuth<{ id: string }>('read')( 500 ) } - } -) + }) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 54265bb687c..98bed818fee 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpDnsResolutionError, McpDomainNotAllowedError, @@ -22,119 +23,124 @@ export const dynamic = 'force-dynamic' /** * PATCH - Update an MCP server in the workspace (requires write or admin permission) */ -export const PATCH = withMcpAuth<{ id: string }>('write')( - async ( - request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, - { params } - ) => { - const { id: serverId } = await params - - try { - const body = getParsedBody(request) || (await request.json()) - - logger.info(`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, { - userId, - updates: Object.keys(body).filter((k) => k !== 'workspaceId'), - }) - - // Remove workspaceId from body to prevent it from being updated - const { workspaceId: _, ...updateData } = body - - if (updateData.url) { - try { - validateMcpDomain(updateData.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) +export const PATCH = + withRouteHandler(withMcpAuth < { id: string }) > + 'write'( + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { + const { id: serverId } = await params + + try { + const body = getParsedBody(request) || (await request.json()) + + logger.info( + `[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, + { + userId, + updates: Object.keys(body).filter((k) => k !== 'workspaceId'), } - throw e - } + ) - try { - await validateMcpServerSsrf(updateData.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) + // Remove workspaceId from body to prevent it from being updated + const { workspaceId: _, ...updateData } = body + + if (updateData.url) { + try { + validateMcpDomain(updateData.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) + + try { + await validateMcpServerSsrf(updateData.url) + } catch (e) { + if (e instanceof McpDnsResolutionError) { + return createMcpErrorResponse(e, e.message, 502) + } + if (e instanceof McpSsrfError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e } - throw e } - } - // Get the current server to check if URL is changing - const [currentServer] = await db - .select({ url: mcpServers.url }) - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) + // Get the current server to check if URL is changing + const [currentServer] = await db + .select({ url: mcpServers.url }) + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) ) - ) - .limit(1) + .limit(1) + + const [updatedServer] = await db + .update(mcpServers) + .set({ + ...updateData, + updatedAt: new Date(), + }) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .returning() - const [updatedServer] = await db - .update(mcpServers) - .set({ - ...updateData, - updatedAt: new Date(), - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) + if (!updatedServer) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 ) - ) - .returning() + } - if (!updatedServer) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } + const shouldClearCache = + (body.url !== undefined && currentServer?.url !== body.url) || + body.enabled !== undefined || + body.headers !== undefined || + body.timeout !== undefined || + body.retries !== undefined - const shouldClearCache = - (body.url !== undefined && currentServer?.url !== body.url) || - body.enabled !== undefined || - body.headers !== undefined || - body.timeout !== undefined || - body.retries !== undefined + if (shouldClearCache) { + await mcpService.clearCache(workspaceId) + logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`) + } - if (shouldClearCache) { - await mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`) - } + logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: updatedServer.name || serverId, + description: `Updated MCP server "${updatedServer.name || serverId}"`, + request, + }) - logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: updatedServer.name || serverId, - description: `Updated MCP server "${updatedServer.name || serverId}"`, - request, - }) - - return createMcpSuccessResponse({ server: updatedServer }) - } catch (error) { - logger.error(`[${requestId}] Error updating MCP server:`, error) - return createMcpErrorResponse( - error instanceof Error ? error : new Error('Failed to update MCP server'), - 'Failed to update MCP server', - 500 - ) + return createMcpSuccessResponse({ server: updatedServer }) + } catch (error) { + logger.error(`[${requestId}] Error updating MCP server:`, error) + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Failed to update MCP server'), + 'Failed to update MCP server', + 500 + ) + } } - } -) + ) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index 054c7a3a2ca..b3eff7d06ff 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpDnsResolutionError, McpDomainNotAllowedError, @@ -28,7 +29,7 @@ export const dynamic = 'force-dynamic' /** * GET - List all registered MCP servers for the workspace */ -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) @@ -63,7 +64,7 @@ export const GET = withMcpAuth('read')( * If a server with the same ID already exists (same URL in same workspace), * it will be updated instead of creating a duplicate. */ -export const POST = withMcpAuth('write')( +export const POST = withRouteHandler(withMcpAuth('write'))( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) @@ -225,7 +226,7 @@ export const POST = withMcpAuth('write')( /** * DELETE - Delete an MCP server from the workspace (requires admin permission) */ -export const DELETE = withMcpAuth('admin')( +export const DELETE = withRouteHandler(withMcpAuth('admin'))( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 37d6696b9c0..a456e836f0c 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpClient } from '@/lib/mcp/client' import { McpDnsResolutionError, @@ -64,7 +65,7 @@ function sanitizeConnectionError(error: unknown): string { /** * POST - Test connection to an MCP server before registering it */ -export const POST = withMcpAuth('write')( +export const POST = withRouteHandler(withMcpAuth('write'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const body: TestConnectionRequest = getParsedBody(request) || (await request.json()) diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index b62470274ae..d9df6f02390 100644 --- a/apps/sim/app/api/mcp/tools/discover/route.ts +++ b/apps/sim/app/api/mcp/tools/discover/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import type { McpToolDiscoveryResponse } from '@/lib/mcp/types' @@ -9,7 +10,7 @@ const logger = createLogger('McpToolDiscoveryAPI') export const dynamic = 'force-dynamic' -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) @@ -45,7 +46,7 @@ export const GET = withMcpAuth('read')( } ) -export const POST = withMcpAuth('read')( +export const POST = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index 258bdbcafde..08ca32245aa 100644 --- a/apps/sim/app/api/mcp/tools/execute/route.ts +++ b/apps/sim/app/api/mcp/tools/execute/route.ts @@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { getExecutionTimeout } from '@/lib/core/execution-limits' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' @@ -40,7 +41,7 @@ function hasType(prop: unknown): prop is SchemaProperty { /** * POST - Execute a tool on an MCP server */ -export const POST = withMcpAuth('read')( +export const POST = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index 5a5519c2777..5635c833281 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -3,6 +3,7 @@ import { workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withMcpAuth } from '@/lib/mcp/middleware' import type { McpToolSchema, StoredMcpTool } from '@/lib/mcp/types' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -11,7 +12,7 @@ const logger = createLogger('McpStoredToolsAPI') export const dynamic = 'force-dynamic' -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index f5ed5371e19..fe493c8febd 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -19,7 +20,7 @@ interface RouteParams { /** * GET - Get a specific workflow MCP server with its tools */ -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { const { id: serverId } = await params @@ -75,7 +76,7 @@ export const GET = withMcpAuth('read')( /** * PATCH - Update a workflow MCP server */ -export const PATCH = withMcpAuth('write')( +export const PATCH = withRouteHandler(withMcpAuth('write'))( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, @@ -153,7 +154,7 @@ export const PATCH = withMcpAuth('write')( /** * DELETE - Delete a workflow MCP server and all its tools */ -export const DELETE = withMcpAuth('admin')( +export const DELETE = withRouteHandler(withMcpAuth('admin'))( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index f54caf4703e..b1d0087a565 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -21,7 +22,7 @@ interface RouteParams { /** * GET - Get a specific tool */ -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { const { id: serverId, toolId } = await params @@ -75,7 +76,7 @@ export const GET = withMcpAuth('read')( /** * PATCH - Update a tool's configuration */ -export const PATCH = withMcpAuth('write')( +export const PATCH = withRouteHandler(withMcpAuth('write'))( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, @@ -171,7 +172,7 @@ export const PATCH = withMcpAuth('write')( /** * DELETE - Remove a tool from an MCP server */ -export const DELETE = withMcpAuth('write')( +export const DELETE = withRouteHandler(withMcpAuth('write'))( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 1a4687b44fc..a0f4834acf1 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -23,7 +24,7 @@ interface RouteParams { /** * GET - List all tools for a workflow MCP server */ -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { try { const { id: serverId } = await params @@ -84,7 +85,7 @@ export const GET = withMcpAuth('read')( /** * POST - Add a workflow as a tool to an MCP server */ -export const POST = withMcpAuth('write')( +export const POST = withRouteHandler(withMcpAuth('write'))( async ( request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }, diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 84d431fa423..f80eb117eb0 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -5,6 +5,7 @@ import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -19,7 +20,7 @@ export const dynamic = 'force-dynamic' /** * GET - List all workflow MCP servers for the workspace */ -export const GET = withMcpAuth('read')( +export const GET = withRouteHandler(withMcpAuth('read'))( async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`) @@ -94,7 +95,7 @@ export const GET = withMcpAuth('read')( /** * POST - Create a new workflow MCP server */ -export const POST = withMcpAuth('write')( +export const POST = withRouteHandler(withMcpAuth('write'))( async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) diff --git a/apps/sim/app/api/memory/[id]/route.ts b/apps/sim/app/api/memory/[id]/route.ts index 4a4c96b117c..d5a6216d1e0 100644 --- a/apps/sim/app/api/memory/[id]/route.ts +++ b/apps/sim/app/api/memory/[id]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MemoryByIdAPI') @@ -72,215 +73,223 @@ async function validateMemoryAccess( export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') + try { + const url = new URL(request.url) + const workspaceId = url.searchParams.get('workspaceId') - const validation = memoryQuerySchema.safeParse({ workspaceId }) - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json( - { success: false, error: { message: errorMessage } }, - { status: 400 } + const validation = memoryQuerySchema.safeParse({ workspaceId }) + if (!validation.success) { + const errorMessage = validation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + return NextResponse.json( + { success: false, error: { message: errorMessage } }, + { status: 400 } + ) + } + + const { workspaceId: validatedWorkspaceId } = validation.data + + const accessCheck = await validateMemoryAccess( + request, + validatedWorkspaceId, + requestId, + 'read' ) - } + if ('error' in accessCheck) { + return accessCheck.error + } - const { workspaceId: validatedWorkspaceId } = validation.data + const memories = await db + .select() + .from(memory) + .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .orderBy(memory.createdAt) + .limit(1) - const accessCheck = await validateMemoryAccess(request, validatedWorkspaceId, requestId, 'read') - if ('error' in accessCheck) { - return accessCheck.error - } + if (memories.length === 0) { + return NextResponse.json( + { success: false, error: { message: 'Memory not found' } }, + { status: 404 } + ) + } - const memories = await db - .select() - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .orderBy(memory.createdAt) - .limit(1) + const mem = memories[0] - if (memories.length === 0) { + logger.info(`[${requestId}] Memory retrieved: ${id} for workspace: ${validatedWorkspaceId}`) return NextResponse.json( - { success: false, error: { message: 'Memory not found' } }, - { status: 404 } + { success: true, data: { conversationId: mem.key, data: mem.data } }, + { status: 200 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error retrieving memory`, { error }) + return NextResponse.json( + { success: false, error: { message: error.message || 'Failed to retrieve memory' } }, + { status: 500 } ) } - - const mem = memories[0] - - logger.info(`[${requestId}] Memory retrieved: ${id} for workspace: ${validatedWorkspaceId}`) - return NextResponse.json( - { success: true, data: { conversationId: mem.key, data: mem.data } }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error retrieving memory`, { error }) - return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to retrieve memory' } }, - { status: 500 } - ) } -} +) -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const url = new URL(request.url) - const workspaceId = url.searchParams.get('workspaceId') - - const validation = memoryQuerySchema.safeParse({ workspaceId }) - if (!validation.success) { - const errorMessage = validation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') - return NextResponse.json( - { success: false, error: { message: errorMessage } }, - { status: 400 } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const url = new URL(request.url) + const workspaceId = url.searchParams.get('workspaceId') + + const validation = memoryQuerySchema.safeParse({ workspaceId }) + if (!validation.success) { + const errorMessage = validation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + return NextResponse.json( + { success: false, error: { message: errorMessage } }, + { status: 400 } + ) + } + + const { workspaceId: validatedWorkspaceId } = validation.data + + const accessCheck = await validateMemoryAccess( + request, + validatedWorkspaceId, + requestId, + 'write' ) - } + if ('error' in accessCheck) { + return accessCheck.error + } - const { workspaceId: validatedWorkspaceId } = validation.data + const existingMemory = await db + .select({ id: memory.id }) + .from(memory) + .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .limit(1) - const accessCheck = await validateMemoryAccess( - request, - validatedWorkspaceId, - requestId, - 'write' - ) - if ('error' in accessCheck) { - return accessCheck.error - } + if (existingMemory.length === 0) { + return NextResponse.json( + { success: false, error: { message: 'Memory not found' } }, + { status: 404 } + ) + } - const existingMemory = await db - .select({ id: memory.id }) - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .limit(1) + await db + .delete(memory) + .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - if (existingMemory.length === 0) { + logger.info(`[${requestId}] Memory deleted: ${id} for workspace: ${validatedWorkspaceId}`) return NextResponse.json( - { success: false, error: { message: 'Memory not found' } }, - { status: 404 } + { success: true, data: { message: 'Memory deleted successfully' } }, + { status: 200 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error deleting memory`, { error }) + return NextResponse.json( + { success: false, error: { message: error.message || 'Failed to delete memory' } }, + { status: 500 } ) } - - await db - .delete(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - - logger.info(`[${requestId}] Memory deleted: ${id} for workspace: ${validatedWorkspaceId}`) - return NextResponse.json( - { success: true, data: { message: 'Memory deleted successfully' } }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error deleting memory`, { error }) - return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to delete memory' } }, - { status: 500 } - ) } -} +) -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - let validatedData - let validatedWorkspaceId try { - const body = await request.json() - const validation = memoryPutBodySchema.safeParse(body) + let validatedData + let validatedWorkspaceId + try { + const body = await request.json() + const validation = memoryPutBodySchema.safeParse(body) + + if (!validation.success) { + const errorMessage = validation.error.errors + .map((err) => `${err.path.join('.')}: ${err.message}`) + .join(', ') + return NextResponse.json( + { success: false, error: { message: `Invalid request body: ${errorMessage}` } }, + { status: 400 } + ) + } + + validatedData = validation.data.data + validatedWorkspaceId = validation.data.workspaceId + } catch { + return NextResponse.json( + { success: false, error: { message: 'Invalid JSON in request body' } }, + { status: 400 } + ) + } - if (!validation.success) { - const errorMessage = validation.error.errors + const accessCheck = await validateMemoryAccess( + request, + validatedWorkspaceId, + requestId, + 'write' + ) + if ('error' in accessCheck) { + return accessCheck.error + } + + const existingMemories = await db + .select() + .from(memory) + .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .limit(1) + + if (existingMemories.length === 0) { + return NextResponse.json( + { success: false, error: { message: 'Memory not found' } }, + { status: 404 } + ) + } + + const agentValidation = agentMemoryDataSchema.safeParse(validatedData) + if (!agentValidation.success) { + const errorMessage = agentValidation.error.errors .map((err) => `${err.path.join('.')}: ${err.message}`) .join(', ') return NextResponse.json( - { success: false, error: { message: `Invalid request body: ${errorMessage}` } }, + { success: false, error: { message: `Invalid agent memory data: ${errorMessage}` } }, { status: 400 } ) } - validatedData = validation.data.data - validatedWorkspaceId = validation.data.workspaceId - } catch { - return NextResponse.json( - { success: false, error: { message: 'Invalid JSON in request body' } }, - { status: 400 } - ) - } + const now = new Date() + await db + .update(memory) + .set({ data: validatedData, updatedAt: now }) + .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - const accessCheck = await validateMemoryAccess( - request, - validatedWorkspaceId, - requestId, - 'write' - ) - if ('error' in accessCheck) { - return accessCheck.error - } + const updatedMemories = await db + .select() + .from(memory) + .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) + .limit(1) - const existingMemories = await db - .select() - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .limit(1) + const mem = updatedMemories[0] - if (existingMemories.length === 0) { + logger.info(`[${requestId}] Memory updated: ${id} for workspace: ${validatedWorkspaceId}`) return NextResponse.json( - { success: false, error: { message: 'Memory not found' } }, - { status: 404 } + { success: true, data: { conversationId: mem.key, data: mem.data } }, + { status: 200 } ) - } - - const agentValidation = agentMemoryDataSchema.safeParse(validatedData) - if (!agentValidation.success) { - const errorMessage = agentValidation.error.errors - .map((err) => `${err.path.join('.')}: ${err.message}`) - .join(', ') + } catch (error: any) { + logger.error(`[${requestId}] Error updating memory`, { error }) return NextResponse.json( - { success: false, error: { message: `Invalid agent memory data: ${errorMessage}` } }, - { status: 400 } + { success: false, error: { message: error.message || 'Failed to update memory' } }, + { status: 500 } ) } - - const now = new Date() - await db - .update(memory) - .set({ data: validatedData, updatedAt: now }) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - - const updatedMemories = await db - .select() - .from(memory) - .where(and(eq(memory.key, id), eq(memory.workspaceId, validatedWorkspaceId))) - .limit(1) - - const mem = updatedMemories[0] - - logger.info(`[${requestId}] Memory updated: ${id} for workspace: ${validatedWorkspaceId}`) - return NextResponse.json( - { success: true, data: { conversationId: mem.key, data: mem.data } }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error updating memory`, { error }) - return NextResponse.json( - { success: false, error: { message: error.message || 'Failed to update memory' } }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/memory/route.ts b/apps/sim/app/api/memory/route.ts index 82ffffe6963..67f577f7067 100644 --- a/apps/sim/app/api/memory/route.ts +++ b/apps/sim/app/api/memory/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MemoryAPI') @@ -13,7 +14,7 @@ const logger = createLogger('MemoryAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -84,9 +85,9 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -223,9 +224,9 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -305,4 +306,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 09dea73a050..461b3de29b9 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -18,6 +18,7 @@ import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/ import { taskPubSub } from '@/lib/copilot/task-events' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -88,7 +89,7 @@ const MothershipMessageSchema = z.object({ * POST /api/mothership/chat * Workspace-scoped chat — no workflowId, proxies to Go /api/mothership. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() let userMessageIdForLogs: string | undefined @@ -414,4 +415,4 @@ export async function POST(req: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts index 70dc1df7c87..f6c76ac0c89 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth' import { releasePendingChatStream } from '@/lib/copilot/chat-streaming' import { taskPubSub } from '@/lib/copilot/task-events' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('MothershipChatStopAPI') @@ -51,7 +52,7 @@ const StopSchema = z.object({ * Persists partial assistant content when the user stops a stream mid-response. * Clears conversationId so the server-side onComplete won't duplicate the message. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -111,4 +112,4 @@ export async function POST(req: NextRequest) { logger.error('Error stopping chat stream:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 3101e681589..b50f2965c3c 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -13,6 +13,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('MothershipChatAPI') @@ -26,217 +27,214 @@ const UpdateChatSchema = z message: 'At least one field must be provided', }) -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ chatId: string }> } -) { - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) => { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } + const { chatId } = await params + if (!chatId) { + return createBadRequestResponse('chatId is required') + } - const chat = await getAccessibleCopilotChat(chatId, userId) - if (!chat || chat.type !== 'mothership') { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) - } + const chat = await getAccessibleCopilotChat(chatId, userId) + if (!chat || chat.type !== 'mothership') { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } - let streamSnapshot: { - events: Array<{ eventId: number; streamId: string; event: Record }> - status: string - } | null = null - - if (chat.conversationId) { - try { - const [meta, events] = await Promise.all([ - getStreamMeta(chat.conversationId), - readStreamEvents(chat.conversationId, 0), - ]) - - streamSnapshot = { - events: events || [], - status: meta?.status || 'unknown', + let streamSnapshot: { + events: Array<{ eventId: number; streamId: string; event: Record }> + status: string + } | null = null + + if (chat.conversationId) { + try { + const [meta, events] = await Promise.all([ + getStreamMeta(chat.conversationId), + readStreamEvents(chat.conversationId, 0), + ]) + + streamSnapshot = { + events: events || [], + status: meta?.status || 'unknown', + } + } catch (error) { + logger + .withMetadata({ messageId: chat.conversationId || undefined }) + .warn('Failed to read stream snapshot for mothership chat', { + chatId, + conversationId: chat.conversationId, + error: error instanceof Error ? error.message : String(error), + }) } - } catch (error) { - logger - .withMetadata({ messageId: chat.conversationId || undefined }) - .warn('Failed to read stream snapshot for mothership chat', { - chatId, - conversationId: chat.conversationId, - error: error instanceof Error ? error.message : String(error), - }) } - } - return NextResponse.json({ - success: true, - chat: { - id: chat.id, - title: chat.title, - messages: Array.isArray(chat.messages) ? chat.messages : [], - conversationId: chat.conversationId || null, - resources: Array.isArray(chat.resources) ? chat.resources : [], - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - ...(streamSnapshot ? { streamSnapshot } : {}), - }, - }) - } catch (error) { - logger.error('Error fetching mothership chat:', error) - return createInternalServerErrorResponse('Failed to fetch chat') - } -} - -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ chatId: string }> } -) { - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() + return NextResponse.json({ + success: true, + chat: { + id: chat.id, + title: chat.title, + messages: Array.isArray(chat.messages) ? chat.messages : [], + conversationId: chat.conversationId || null, + resources: Array.isArray(chat.resources) ? chat.resources : [], + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + ...(streamSnapshot ? { streamSnapshot } : {}), + }, + }) + } catch (error) { + logger.error('Error fetching mothership chat:', error) + return createInternalServerErrorResponse('Failed to fetch chat') } + } +) + +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) => { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } + const { chatId } = await params + if (!chatId) { + return createBadRequestResponse('chatId is required') + } - const body = await request.json() - const { title, isUnread } = UpdateChatSchema.parse(body) + const body = await request.json() + const { title, isUnread } = UpdateChatSchema.parse(body) - const updates: Record = {} + const updates: Record = {} - if (title !== undefined) { - const now = new Date() - updates.title = title - updates.updatedAt = now - if (isUnread === undefined) { - updates.lastSeenAt = now + if (title !== undefined) { + const now = new Date() + updates.title = title + updates.updatedAt = now + if (isUnread === undefined) { + updates.lastSeenAt = now + } + } + if (isUnread !== undefined) { + updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())` } - } - if (isUnread !== undefined) { - updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())` - } - const [updatedChat] = await db - .update(copilotChats) - .set(updates) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - eq(copilotChats.type, 'mothership') + const [updatedChat] = await db + .update(copilotChats) + .set(updates) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.type, 'mothership') + ) ) - ) - .returning({ - id: copilotChats.id, - workspaceId: copilotChats.workspaceId, - }) + .returning({ + id: copilotChats.id, + workspaceId: copilotChats.workspaceId, + }) + + if (!updatedChat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } - if (!updatedChat) { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + if (updatedChat.workspaceId) { + if (title !== undefined) { + taskPubSub?.publishStatusChanged({ + workspaceId: updatedChat.workspaceId, + chatId, + type: 'renamed', + }) + captureServerEvent( + userId, + 'task_renamed', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } + if (isUnread === true) { + captureServerEvent( + userId, + 'task_marked_unread', + { workspace_id: updatedChat.workspaceId }, + { + groups: { workspace: updatedChat.workspaceId }, + } + ) + } + } + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return createBadRequestResponse('Invalid request data') + } + logger.error('Error updating mothership chat:', error) + return createInternalServerErrorResponse('Failed to update chat') } + } +) + +export const DELETE = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ chatId: string }> }) => { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } - if (updatedChat.workspaceId) { - if (title !== undefined) { + const { chatId } = await params + if (!chatId) { + return createBadRequestResponse('chatId is required') + } + + const chat = await getAccessibleCopilotChat(chatId, userId) + if (!chat || chat.type !== 'mothership') { + return NextResponse.json({ success: true }) + } + + const [deletedChat] = await db + .delete(copilotChats) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.type, 'mothership') + ) + ) + .returning({ + workspaceId: copilotChats.workspaceId, + }) + + if (!deletedChat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + + if (deletedChat.workspaceId) { taskPubSub?.publishStatusChanged({ - workspaceId: updatedChat.workspaceId, + workspaceId: deletedChat.workspaceId, chatId, - type: 'renamed', + type: 'deleted', }) captureServerEvent( userId, - 'task_renamed', - { workspace_id: updatedChat.workspaceId }, - { - groups: { workspace: updatedChat.workspaceId }, - } - ) - } - if (isUnread === true) { - captureServerEvent( - userId, - 'task_marked_unread', - { workspace_id: updatedChat.workspaceId }, + 'task_deleted', + { workspace_id: deletedChat.workspaceId }, { - groups: { workspace: updatedChat.workspaceId }, + groups: { workspace: deletedChat.workspaceId }, } ) } - } - - return NextResponse.json({ success: true }) - } catch (error) { - if (error instanceof z.ZodError) { - return createBadRequestResponse('Invalid request data') - } - logger.error('Error updating mothership chat:', error) - return createInternalServerErrorResponse('Failed to update chat') - } -} - -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ chatId: string }> } -) { - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } - - const chat = await getAccessibleCopilotChat(chatId, userId) - if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting mothership chat:', error) + return createInternalServerErrorResponse('Failed to delete chat') } - - const [deletedChat] = await db - .delete(copilotChats) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - eq(copilotChats.type, 'mothership') - ) - ) - .returning({ - workspaceId: copilotChats.workspaceId, - }) - - if (!deletedChat) { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) - } - - if (deletedChat.workspaceId) { - taskPubSub?.publishStatusChanged({ - workspaceId: deletedChat.workspaceId, - chatId, - type: 'deleted', - }) - captureServerEvent( - userId, - 'task_deleted', - { workspace_id: deletedChat.workspaceId }, - { - groups: { workspace: deletedChat.workspaceId }, - } - ) - } - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting mothership chat:', error) - return createInternalServerErrorResponse('Failed to delete chat') } -} +) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index bc694d1d9fe..d05dffaa4b8 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -11,6 +11,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -20,7 +21,7 @@ const logger = createLogger('MothershipChatsAPI') * GET /api/mothership/chats?workspaceId=xxx * Returns mothership (home) chats for the authenticated user in the given workspace. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -57,7 +58,7 @@ export async function GET(request: NextRequest) { logger.error('Error fetching mothership chats:', error) return createInternalServerErrorResponse('Failed to fetch chats') } -} +}) const CreateChatSchema = z.object({ workspaceId: z.string().min(1), @@ -67,7 +68,7 @@ const CreateChatSchema = z.object({ * POST /api/mothership/chats * Creates an empty mothership chat and returns its ID. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -113,4 +114,4 @@ export async function POST(request: NextRequest) { logger.error('Error creating mothership chat:', error) return createInternalServerErrorResponse('Failed to create chat') } -} +}) diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index 38abba7b33f..0a6466de945 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -8,25 +8,28 @@ */ import { taskPubSub } from '@/lib/copilot/task-events' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' export const dynamic = 'force-dynamic' -export const GET = createWorkspaceSSE({ - label: 'mothership-events', - subscriptions: [ - { - subscribe: (workspaceId, send) => { - if (!taskPubSub) return () => {} - return taskPubSub.onStatusChanged((event) => { - if (event.workspaceId !== workspaceId) return - send('task_status', { - chatId: event.chatId, - type: event.type, - timestamp: Date.now(), +export const GET = withRouteHandler( + createWorkspaceSSE({ + label: 'mothership-events', + subscriptions: [ + { + subscribe: (workspaceId, send) => { + if (!taskPubSub) return () => {} + return taskPubSub.onStatusChanged((event) => { + if (event.workspaceId !== workspaceId) return + send('task_status', { + chatId: event.chatId, + type: event.type, + timestamp: Date.now(), + }) }) - }) + }, }, - }, - ], -}) + ], + }) +) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 619e0135726..9b4817041d3 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -7,6 +7,7 @@ import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -36,7 +37,7 @@ const ExecuteRequestSchema = z.object({ * Called by the executor via internal JWT auth, not by the browser directly. * Consumes the Go SSE stream internally and returns a single JSON response. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined try { @@ -145,4 +146,4 @@ export async function POST(req: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/notifications/poll/route.ts b/apps/sim/app/api/notifications/poll/route.ts index 010f9ccffcc..7656e72083b 100644 --- a/apps/sim/app/api/notifications/poll/route.ts +++ b/apps/sim/app/api/notifications/poll/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollInactivityAlerts } from '@/lib/notifications/inactivity-polling' const logger = createLogger('InactivityAlertPoll') @@ -12,7 +13,7 @@ export const maxDuration = 120 const LOCK_KEY = 'inactivity-alert-polling-lock' const LOCK_TTL_SECONDS = 120 -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateShortId() logger.info(`Inactivity alert polling triggered (${requestId})`) @@ -63,4 +64,4 @@ export async function GET(request: NextRequest) { await releaseLock(LOCK_KEY, requestId).catch(() => {}) } } -} +}) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index f54e72b2701..1f3245d76ed 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -27,6 +27,7 @@ import { requireStripeClient } from '@/lib/billing/stripe-client' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -39,598 +40,606 @@ const updateInvitationSchema = z.object({ }) // Get invitation details -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const { id: organizationId, invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteHandler( + async ( + _request: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } + ) => { + const { id: organizationId, invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - try { - const orgInvitation = await db - .select() - .from(invitation) - .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) - .then((rows) => rows[0]) + try { + const orgInvitation = await db + .select() + .from(invitation) + .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) + .then((rows) => rows[0]) - if (!orgInvitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } + if (!orgInvitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } - // Verify caller is either an org member or the invitee - const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase() + // Verify caller is either an org member or the invitee + const isInvitee = session.user.email?.toLowerCase() === orgInvitation.email.toLowerCase() - if (!isInvitee) { - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + if (!isInvitee) { + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (memberEntry.length === 0) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + if (memberEntry.length === 0) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } } - } - const org = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .then((rows) => rows[0]) + const org = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .then((rows) => rows[0]) - if (!org) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - return NextResponse.json({ - invitation: orgInvitation, - organization: org, - }) - } catch (error) { - logger.error('Error fetching organization invitation:', error) - return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) + return NextResponse.json({ + invitation: orgInvitation, + organization: org, + }) + } catch (error) { + logger.error('Error fetching organization invitation:', error) + return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) + } } -} +) // Resend invitation -export async function POST( - _request: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const { id: organizationId, invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteHandler( + async ( + _request: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } + ) => { + const { id: organizationId, invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + // Verify user is admin/owner + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - try { - // Verify user is admin/owner - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + const orgInvitation = await db + .select() + .from(invitation) + .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) + .then((rows) => rows[0]) - const orgInvitation = await db - .select() - .from(invitation) - .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) - .then((rows) => rows[0]) + if (!orgInvitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } - if (!orgInvitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } + if (orgInvitation.status !== 'pending') { + return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) + } - if (orgInvitation.status !== 'pending') { - return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) - } + const org = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .then((rows) => rows[0]) - const org = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .then((rows) => rows[0]) - - const inviter = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - // Update expiration date - const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days - await db - .update(invitation) - .set({ expiresAt: newExpiresAt }) - .where(eq(invitation.id, invitationId)) - - // Send email - const emailHtml = await renderInvitationEmail( - inviter[0]?.name || 'Someone', - org?.name || 'organization', - `${getBaseUrl()}/invite/${invitationId}` - ) + const inviter = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) - const emailResult = await sendEmail({ - to: orgInvitation.email, - subject: getEmailSubject('invitation'), - html: emailHtml, - emailType: 'transactional', - }) + // Update expiration date + const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days + await db + .update(invitation) + .set({ expiresAt: newExpiresAt }) + .where(eq(invitation.id, invitationId)) + + // Send email + const emailHtml = await renderInvitationEmail( + inviter[0]?.name || 'Someone', + org?.name || 'organization', + `${getBaseUrl()}/invite/${invitationId}` + ) + + const emailResult = await sendEmail({ + to: orgInvitation.email, + subject: getEmailSubject('invitation'), + html: emailHtml, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.error('Failed to resend invitation email', { + email: orgInvitation.email, + error: emailResult.message, + }) + return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 }) + } - if (!emailResult.success) { - logger.error('Failed to resend invitation email', { + logger.info('Organization invitation resent', { + organizationId, + invitationId, + resentBy: session.user.id, email: orgInvitation.email, - error: emailResult.message, }) - return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 }) - } - logger.info('Organization invitation resent', { - organizationId, - invitationId, - resentBy: session.user.id, - email: orgInvitation.email, - }) - - return NextResponse.json({ - success: true, - message: 'Invitation resent successfully', - }) - } catch (error) { - logger.error('Error resending organization invitation:', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) - } -} - -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } -) { - const { id: organizationId, invitationId } = await params - - logger.info( - '[PUT /api/organizations/[id]/invitations/[invitationId]] Invitation acceptance request', - { - organizationId, - invitationId, - path: req.url, + return NextResponse.json({ + success: true, + message: 'Invitation resent successfully', + }) + } catch (error) { + logger.error('Error resending organization invitation:', error) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) } - ) - - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } +) + +export const PUT = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } + ) => { + const { id: organizationId, invitationId } = await params + + logger.info( + '[PUT /api/organizations/[id]/invitations/[invitationId]] Invitation acceptance request', + { + organizationId, + invitationId, + path: req.url, + } + ) - try { - const body = await req.json() + const session = await getSession() - const validation = updateInvitationSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { status } = validation.data - - const orgInvitation = await db - .select() - .from(invitation) - .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) - .then((rows) => rows[0]) + try { + const body = await req.json() - if (!orgInvitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } + const validation = updateInvitationSchema.safeParse(body) + if (!validation.success) { + const firstError = validation.error.errors[0] + return NextResponse.json({ error: firstError.message }, { status: 400 }) + } - if (orgInvitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 }) - } + const { status } = validation.data - if (status === 'accepted') { - const userData = await db + const orgInvitation = await db .select() - .from(user) - .where(eq(user.id, session.user.id)) + .from(invitation) + .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) .then((rows) => rows[0]) - if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) { - return NextResponse.json( - { error: 'Email mismatch. You can only accept invitations sent to your email address.' }, - { status: 403 } - ) + if (!orgInvitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) } - } - if (status === 'cancelled') { - const isAdmin = await db - .select() - .from(member) - .where( - and( - eq(member.organizationId, organizationId), - eq(member.userId, session.user.id), - eq(member.role, 'admin') - ) - ) - .then((rows) => rows.length > 0) - - if (!isAdmin) { - return NextResponse.json( - { error: 'Only organization admins can cancel invitations' }, - { status: 403 } - ) + if (orgInvitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 }) } - } - - // Enforce: user can only be part of a single organization - if (status === 'accepted') { - // Check if user is already a member of ANY organization - const existingOrgMemberships = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, session.user.id)) - if (existingOrgMemberships.length > 0) { - // Check if already a member of THIS specific organization - const alreadyMemberOfThisOrg = existingOrgMemberships.some( - (m) => m.organizationId === organizationId - ) + if (status === 'accepted') { + const userData = await db + .select() + .from(user) + .where(eq(user.id, session.user.id)) + .then((rows) => rows[0]) - if (alreadyMemberOfThisOrg) { + if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) { return NextResponse.json( - { error: 'You are already a member of this organization' }, - { status: 400 } + { + error: 'Email mismatch. You can only accept invitations sent to your email address.', + }, + { status: 403 } ) } - - // Member of a different organization - // Mark the invitation as rejected since they can't accept it - await db - .update(invitation) - .set({ - status: 'rejected', - }) - .where(eq(invitation.id, invitationId)) - - return NextResponse.json( - { - error: - 'You are already a member of an organization. Leave your current organization before accepting a new invitation.', - }, - { status: 409 } - ) } - } - let personalProToCancel: any = null + if (status === 'cancelled') { + const isAdmin = await db + .select() + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + eq(member.userId, session.user.id), + eq(member.role, 'admin') + ) + ) + .then((rows) => rows.length > 0) - await db.transaction(async (tx) => { - await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) + if (!isAdmin) { + return NextResponse.json( + { error: 'Only organization admins can cancel invitations' }, + { status: 403 } + ) + } + } + // Enforce: user can only be part of a single organization if (status === 'accepted') { - await tx.insert(member).values({ - id: generateId(), - userId: session.user.id, - organizationId, - role: orgInvitation.role, - createdAt: new Date(), - }) + // Check if user is already a member of ANY organization + const existingOrgMemberships = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, session.user.id)) + + if (existingOrgMemberships.length > 0) { + // Check if already a member of THIS specific organization + const alreadyMemberOfThisOrg = existingOrgMemberships.some( + (m) => m.organizationId === organizationId + ) - // Snapshot Pro usage and cancel Pro subscription when joining a paid team - try { - const orgSubs = await tx - .select() - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.referenceId, organizationId), - inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) - ) + if (alreadyMemberOfThisOrg) { + return NextResponse.json( + { error: 'You are already a member of this organization' }, + { status: 400 } ) - .limit(1) + } - const orgSub = orgSubs[0] - const orgIsPaid = orgSub && isOrgPlan(orgSub.plan) + // Member of a different organization + // Mark the invitation as rejected since they can't accept it + await db + .update(invitation) + .set({ + status: 'rejected', + }) + .where(eq(invitation.id, invitationId)) + + return NextResponse.json( + { + error: + 'You are already a member of an organization. Leave your current organization before accepting a new invitation.', + }, + { status: 409 } + ) + } + } - if (orgIsPaid) { - const userId = session.user.id + let personalProToCancel: any = null - // Find user's active personal Pro subscription - const personalSubs = await tx + await db.transaction(async (tx) => { + await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId)) + + if (status === 'accepted') { + await tx.insert(member).values({ + id: generateId(), + userId: session.user.id, + organizationId, + role: orgInvitation.role, + createdAt: new Date(), + }) + + // Snapshot Pro usage and cancel Pro subscription when joining a paid team + try { + const orgSubs = await tx .select() .from(subscriptionTable) .where( and( - eq(subscriptionTable.referenceId, userId), - inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), - sqlIsPro(subscriptionTable.plan) + eq(subscriptionTable.referenceId, organizationId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) ) ) .limit(1) - const personalPro = personalSubs[0] - if (personalPro) { - // Snapshot the current Pro usage before resetting - const userStatsRows = await tx - .select({ - currentPeriodCost: userStats.currentPeriodCost, - }) - .from(userStats) - .where(eq(userStats.userId, userId)) + const orgSub = orgSubs[0] + const orgIsPaid = orgSub && isOrgPlan(orgSub.plan) + + if (orgIsPaid) { + const userId = session.user.id + + // Find user's active personal Pro subscription + const personalSubs = await tx + .select() + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, userId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES), + sqlIsPro(subscriptionTable.plan) + ) + ) .limit(1) - if (userStatsRows.length > 0) { - const currentProUsage = userStatsRows[0].currentPeriodCost || '0' - - // Snapshot Pro usage and reset currentPeriodCost so new usage goes to team - await tx - .update(userStats) - .set({ - proPeriodCostSnapshot: currentProUsage, - currentPeriodCost: '0', // Reset so new usage is attributed to team - currentPeriodCopilotCost: '0', // Reset copilot cost for new period + const personalPro = personalSubs[0] + if (personalPro) { + // Snapshot the current Pro usage before resetting + const userStatsRows = await tx + .select({ + currentPeriodCost: userStats.currentPeriodCost, }) + .from(userStats) .where(eq(userStats.userId, userId)) + .limit(1) + + if (userStatsRows.length > 0) { + const currentProUsage = userStatsRows[0].currentPeriodCost || '0' + + // Snapshot Pro usage and reset currentPeriodCost so new usage goes to team + await tx + .update(userStats) + .set({ + proPeriodCostSnapshot: currentProUsage, + currentPeriodCost: '0', // Reset so new usage is attributed to team + currentPeriodCopilotCost: '0', // Reset copilot cost for new period + }) + .where(eq(userStats.userId, userId)) + + logger.info('Snapshotted Pro usage when joining team', { + userId, + proUsageSnapshot: currentProUsage, + organizationId, + }) + } - logger.info('Snapshotted Pro usage when joining team', { - userId, - proUsageSnapshot: currentProUsage, - organizationId, - }) + // Mark for cancellation after transaction + if (personalPro.cancelAtPeriodEnd !== true) { + personalProToCancel = personalPro + } } + } + } catch (error) { + logger.error('Failed to handle Pro user joining team', { + userId: session.user.id, + organizationId, + error, + }) + // Don't fail the whole invitation acceptance due to this + } + + // Auto-assign to permission group if one has autoAddNewMembers enabled + try { + const hasAccessControl = await hasAccessControlAccess(session.user.id) + if (hasAccessControl) { + const [autoAddGroup] = await tx + .select({ id: permissionGroup.id, name: permissionGroup.name }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.organizationId, organizationId), + eq(permissionGroup.autoAddNewMembers, true) + ) + ) + .limit(1) - // Mark for cancellation after transaction - if (personalPro.cancelAtPeriodEnd !== true) { - personalProToCancel = personalPro + if (autoAddGroup) { + await tx.insert(permissionGroupMember).values({ + id: generateId(), + permissionGroupId: autoAddGroup.id, + userId: session.user.id, + assignedBy: null, + assignedAt: new Date(), + }) + + logger.info('Auto-assigned new member to permission group', { + userId: session.user.id, + organizationId, + permissionGroupId: autoAddGroup.id, + permissionGroupName: autoAddGroup.name, + }) } } + } catch (error) { + logger.error('Failed to auto-assign user to permission group', { + userId: session.user.id, + organizationId, + error, + }) + // Don't fail the whole invitation acceptance due to this } - } catch (error) { - logger.error('Failed to handle Pro user joining team', { - userId: session.user.id, - organizationId, - error, - }) - // Don't fail the whole invitation acceptance due to this - } - // Auto-assign to permission group if one has autoAddNewMembers enabled - try { - const hasAccessControl = await hasAccessControlAccess(session.user.id) - if (hasAccessControl) { - const [autoAddGroup] = await tx - .select({ id: permissionGroup.id, name: permissionGroup.name }) - .from(permissionGroup) + const linkedWorkspaceInvitations = await tx + .select() + .from(workspaceInvitation) + .where( + and( + eq(workspaceInvitation.orgInvitationId, invitationId), + eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus) + ) + ) + + for (const wsInvitation of linkedWorkspaceInvitations) { + await tx + .update(workspaceInvitation) + .set({ + status: 'accepted' as WorkspaceInvitationStatus, + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, wsInvitation.id)) + + const existingPermission = await tx + .select({ id: permissions.id, permissionType: permissions.permissionType }) + .from(permissions) .where( and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.autoAddNewMembers, true) + eq(permissions.entityId, wsInvitation.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) ) ) - .limit(1) + .then((rows) => rows[0]) - if (autoAddGroup) { - await tx.insert(permissionGroupMember).values({ - id: generateId(), - permissionGroupId: autoAddGroup.id, - userId: session.user.id, - assignedBy: null, - assignedAt: new Date(), - }) + if (existingPermission) { + const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const + type PermissionLevel = keyof typeof PERMISSION_RANK + const existingRank = + PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0 + const newPermission = (wsInvitation.permissions || 'read') as PermissionLevel + const newRank = PERMISSION_RANK[newPermission] ?? 0 - logger.info('Auto-assigned new member to permission group', { + if (newRank > existingRank) { + await tx + .update(permissions) + .set({ + permissionType: newPermission, + updatedAt: new Date(), + }) + .where(eq(permissions.id, existingPermission.id)) + } + } else { + await tx.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: wsInvitation.workspaceId, userId: session.user.id, - organizationId, - permissionGroupId: autoAddGroup.id, - permissionGroupName: autoAddGroup.name, + permissionType: wsInvitation.permissions || 'read', + createdAt: new Date(), + updatedAt: new Date(), }) } } - } catch (error) { - logger.error('Failed to auto-assign user to permission group', { - userId: session.user.id, - organizationId, - error, - }) - // Don't fail the whole invitation acceptance due to this + } else if (status === 'cancelled') { + await tx + .update(workspaceInvitation) + .set({ status: 'cancelled' as WorkspaceInvitationStatus }) + .where(eq(workspaceInvitation.orgInvitationId, invitationId)) } + }) - const linkedWorkspaceInvitations = await tx - .select() + if (status === 'accepted') { + const acceptedWsInvitations = await db + .select({ workspaceId: workspaceInvitation.workspaceId }) .from(workspaceInvitation) .where( and( eq(workspaceInvitation.orgInvitationId, invitationId), - eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus) + eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus) ) ) - for (const wsInvitation of linkedWorkspaceInvitations) { - await tx - .update(workspaceInvitation) - .set({ - status: 'accepted' as WorkspaceInvitationStatus, - updatedAt: new Date(), - }) - .where(eq(workspaceInvitation.id, wsInvitation.id)) - - const existingPermission = await tx - .select({ id: permissions.id, permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.entityId, wsInvitation.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { - const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const - type PermissionLevel = keyof typeof PERMISSION_RANK - const existingRank = - PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0 - const newPermission = (wsInvitation.permissions || 'read') as PermissionLevel - const newRank = PERMISSION_RANK[newPermission] ?? 0 - - if (newRank > existingRank) { - await tx - .update(permissions) - .set({ - permissionType: newPermission, - updatedAt: new Date(), - }) - .where(eq(permissions.id, existingPermission.id)) - } - } else { - await tx.insert(permissions).values({ - id: generateId(), - entityType: 'workspace', - entityId: wsInvitation.workspaceId, - userId: session.user.id, - permissionType: wsInvitation.permissions || 'read', - createdAt: new Date(), - updatedAt: new Date(), + for (const wsInv of acceptedWsInvitations) { + const [wsEnvRow] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId)) + .limit(1) + const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId: wsInv.workspaceId, + envKeys: wsEnvKeys, + actingUserId: session.user.id, }) } } - } else if (status === 'cancelled') { - await tx - .update(workspaceInvitation) - .set({ status: 'cancelled' as WorkspaceInvitationStatus }) - .where(eq(workspaceInvitation.orgInvitationId, invitationId)) } - }) - - if (status === 'accepted') { - const acceptedWsInvitations = await db - .select({ workspaceId: workspaceInvitation.workspaceId }) - .from(workspaceInvitation) - .where( - and( - eq(workspaceInvitation.orgInvitationId, invitationId), - eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus) - ) - ) - for (const wsInv of acceptedWsInvitations) { - const [wsEnvRow] = await db - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId)) - .limit(1) - const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) - if (wsEnvKeys.length > 0) { - await syncWorkspaceEnvCredentials({ - workspaceId: wsInv.workspaceId, - envKeys: wsEnvKeys, - actingUserId: session.user.id, + // Handle Pro subscription cancellation after transaction commits + if (personalProToCancel) { + try { + const stripe = requireStripeClient() + if (personalProToCancel.stripeSubscriptionId) { + try { + await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, { + cancel_at_period_end: true, + }) + } catch (stripeError) { + logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', { + userId: session.user.id, + subscriptionId: personalProToCancel.id, + stripeSubscriptionId: personalProToCancel.stripeSubscriptionId, + error: stripeError, + }) + } + } + + await db + .update(subscriptionTable) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscriptionTable.id, personalProToCancel.id)) + + logger.info('Auto-cancelled personal Pro at period end after joining paid team', { + userId: session.user.id, + personalSubscriptionId: personalProToCancel.id, + organizationId, + }) + } catch (dbError) { + logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', { + userId: session.user.id, + subscriptionId: personalProToCancel.id, + error: dbError, }) } } - } - // Handle Pro subscription cancellation after transaction commits - if (personalProToCancel) { - try { - const stripe = requireStripeClient() - if (personalProToCancel.stripeSubscriptionId) { - try { - await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, { - cancel_at_period_end: true, - }) - } catch (stripeError) { - logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', { - userId: session.user.id, - subscriptionId: personalProToCancel.id, - stripeSubscriptionId: personalProToCancel.stripeSubscriptionId, - error: stripeError, - }) - } + if (status === 'accepted') { + try { + await syncUsageLimitsFromSubscription(session.user.id) + } catch (syncError) { + logger.error('Failed to sync usage limits after joining org', { + userId: session.user.id, + organizationId, + error: syncError, + }) } + } - await db - .update(subscriptionTable) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscriptionTable.id, personalProToCancel.id)) + logger.info(`Organization invitation ${status}`, { + organizationId, + invitationId, + userId: session.user.id, + email: orgInvitation.email, + }) - logger.info('Auto-cancelled personal Pro at period end after joining paid team', { - userId: session.user.id, - personalSubscriptionId: personalProToCancel.id, - organizationId, - }) - } catch (dbError) { - logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', { - userId: session.user.id, - subscriptionId: personalProToCancel.id, - error: dbError, - }) - } - } + const auditActionMap = { + accepted: AuditAction.ORG_INVITATION_ACCEPTED, + rejected: AuditAction.ORG_INVITATION_REJECTED, + cancelled: AuditAction.ORG_INVITATION_CANCELLED, + } as const + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: auditActionMap[status], + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Organization invitation ${status} for ${orgInvitation.email}`, + metadata: { + invitationId, + targetEmail: orgInvitation.email, + targetRole: orgInvitation.role, + status, + }, + request: req, + }) - if (status === 'accepted') { - try { - await syncUsageLimitsFromSubscription(session.user.id) - } catch (syncError) { - logger.error('Failed to sync usage limits after joining org', { - userId: session.user.id, - organizationId, - error: syncError, - }) - } + return NextResponse.json({ + success: true, + message: `Invitation ${status} successfully`, + invitation: { ...orgInvitation, status }, + }) + } catch (error) { + logger.error(`Error updating organization invitation:`, error) + return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) } - - logger.info(`Organization invitation ${status}`, { - organizationId, - invitationId, - userId: session.user.id, - email: orgInvitation.email, - }) - - const auditActionMap = { - accepted: AuditAction.ORG_INVITATION_ACCEPTED, - rejected: AuditAction.ORG_INVITATION_REJECTED, - cancelled: AuditAction.ORG_INVITATION_CANCELLED, - } as const - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: auditActionMap[status], - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Organization invitation ${status} for ${orgInvitation.email}`, - metadata: { - invitationId, - targetEmail: orgInvitation.email, - targetRole: orgInvitation.role, - status, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - message: `Invitation ${status} successfully`, - invitation: { ...orgInvitation, status }, - }) - } catch (error) { - logger.error(`Error updating organization invitation:`, error) - return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 001184d98e7..684fdb6d161 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -24,6 +24,7 @@ import { } from '@/lib/billing/validation/seat-management' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' @@ -43,68 +44,70 @@ interface WorkspaceInvitation { * GET /api/organizations/[id]/invitations * Get all pending invitations for an organization */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const GET = 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 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId } = await params + const { id: organizationId } = await params - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) + const userRole = memberEntry[0].role + const hasAdminAccess = ['owner', 'admin'].includes(userRole) - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + if (!hasAdminAccess) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - const invitations = await db - .select({ - id: invitation.id, - email: invitation.email, - role: invitation.role, - status: invitation.status, - expiresAt: invitation.expiresAt, - createdAt: invitation.createdAt, - inviterName: user.name, - inviterEmail: user.email, + const invitations = await db + .select({ + id: invitation.id, + email: invitation.email, + role: invitation.role, + status: invitation.status, + expiresAt: invitation.expiresAt, + createdAt: invitation.createdAt, + inviterName: user.name, + inviterEmail: user.email, + }) + .from(invitation) + .leftJoin(user, eq(invitation.inviterId, user.id)) + .where(eq(invitation.organizationId, organizationId)) + .orderBy(invitation.createdAt) + + return NextResponse.json({ + success: true, + data: { + invitations, + userRole, + }, + }) + } catch (error) { + logger.error('Failed to get organization invitations', { + organizationId: (await params).id, + error, }) - .from(invitation) - .leftJoin(user, eq(invitation.inviterId, user.id)) - .where(eq(invitation.organizationId, organizationId)) - .orderBy(invitation.createdAt) - - return NextResponse.json({ - success: true, - data: { - invitations, - userRole, - }, - }) - } catch (error) { - logger.error('Failed to get organization invitations', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * POST /api/organizations/[id]/invitations @@ -113,465 +116,468 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ * - ?validate=true - Only validate, don't send invitations * - ?batch=true - Include workspace invitations */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const POST = 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 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - await validateInvitationsAllowed(session.user.id) + await validateInvitationsAllowed(session.user.id) - const { id: organizationId } = await params - const url = new URL(request.url) - const validateOnly = url.searchParams.get('validate') === 'true' - const isBatch = url.searchParams.get('batch') === 'true' + const { id: organizationId } = await params + const url = new URL(request.url) + const validateOnly = url.searchParams.get('validate') === 'true' + const isBatch = url.searchParams.get('batch') === 'true' - const body = await request.json() - const { email, emails, role = 'member', workspaceInvitations } = body + const body = await request.json() + const { email, emails, role = 'member', workspaceInvitations } = body - const invitationEmails = email ? [email] : emails + const invitationEmails = email ? [email] : emails - if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) { - return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 }) - } + if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) { + return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 }) + } - if (!['member', 'admin'].includes(role)) { - return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) - } + if (!['member', 'admin'].includes(role)) { + return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) + } - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - if (validateOnly) { - const validationResult = await validateBulkInvitations(organizationId, invitationEmails) + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - logger.info('Invitation validation completed', { - organizationId, - userId: session.user.id, - emailCount: invitationEmails.length, - result: validationResult, - }) + if (validateOnly) { + const validationResult = await validateBulkInvitations(organizationId, invitationEmails) - return NextResponse.json({ - success: true, - data: validationResult, - validatedBy: session.user.id, - validatedAt: new Date().toISOString(), - }) - } + logger.info('Invitation validation completed', { + organizationId, + userId: session.user.id, + emailCount: invitationEmails.length, + result: validationResult, + }) - const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length) + return NextResponse.json({ + success: true, + data: validationResult, + validatedBy: session.user.id, + validatedAt: new Date().toISOString(), + }) + } - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason, - seatInfo: { - currentSeats: seatValidation.currentSeats, - maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats, - seatsRequested: invitationEmails.length, + const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length) + + if (!seatValidation.canInvite) { + return NextResponse.json( + { + error: seatValidation.reason, + seatInfo: { + currentSeats: seatValidation.currentSeats, + maxSeats: seatValidation.maxSeats, + availableSeats: seatValidation.availableSeats, + seatsRequested: invitationEmails.length, + }, }, - }, - { status: 400 } - ) - } + { status: 400 } + ) + } - const organizationEntry = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const organizationEntry = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (organizationEntry.length === 0) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } + if (organizationEntry.length === 0) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - const processedEmails = invitationEmails - .map((email: string) => { - const normalized = email.trim().toLowerCase() - const validation = quickValidateEmail(normalized) - return validation.isValid ? normalized : null - }) - .filter(Boolean) as string[] + const processedEmails = invitationEmails + .map((email: string) => { + const normalized = email.trim().toLowerCase() + const validation = quickValidateEmail(normalized) + return validation.isValid ? normalized : null + }) + .filter(Boolean) as string[] - if (processedEmails.length === 0) { - return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 }) - } + if (processedEmails.length === 0) { + return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 }) + } - const validWorkspaceInvitations: WorkspaceInvitation[] = [] - if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) { - for (const wsInvitation of workspaceInvitations) { - const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId) + const validWorkspaceInvitations: WorkspaceInvitation[] = [] + if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) { + for (const wsInvitation of workspaceInvitations) { + const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId) + + if (!canInvite) { + return NextResponse.json( + { + error: `You don't have permission to invite users to workspace ${wsInvitation.workspaceId}`, + }, + { status: 403 } + ) + } - if (!canInvite) { - return NextResponse.json( - { - error: `You don't have permission to invite users to workspace ${wsInvitation.workspaceId}`, - }, - { status: 403 } - ) + validWorkspaceInvitations.push(wsInvitation) } - - validWorkspaceInvitations.push(wsInvitation) } - } - const existingMembers = await db - .select({ userEmail: user.email }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(eq(member.organizationId, organizationId)) - - const existingEmails = existingMembers.map((m) => m.userEmail) - const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email)) - - const existingInvitations = await db - .select({ email: invitation.email }) - .from(invitation) - .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) - - const pendingEmails = existingInvitations.map((i) => i.email) - const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email)) - - if (emailsToInvite.length === 0) { - const isSingleEmail = processedEmails.length === 1 - const existingMembersEmails = processedEmails.filter((email: string) => - existingEmails.includes(email) - ) - const pendingInvitationEmails = processedEmails.filter((email: string) => - pendingEmails.includes(email) - ) - - if (isSingleEmail) { - if (existingMembersEmails.length > 0) { - return NextResponse.json( - { - error: 'Failed to send invitation. User is already a part of the organization.', - }, - { status: 400 } - ) + const existingMembers = await db + .select({ userEmail: user.email }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(eq(member.organizationId, organizationId)) + + const existingEmails = existingMembers.map((m) => m.userEmail) + const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email)) + + const existingInvitations = await db + .select({ email: invitation.email }) + .from(invitation) + .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + + const pendingEmails = existingInvitations.map((i) => i.email) + const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email)) + + if (emailsToInvite.length === 0) { + const isSingleEmail = processedEmails.length === 1 + const existingMembersEmails = processedEmails.filter((email: string) => + existingEmails.includes(email) + ) + const pendingInvitationEmails = processedEmails.filter((email: string) => + pendingEmails.includes(email) + ) + + if (isSingleEmail) { + if (existingMembersEmails.length > 0) { + return NextResponse.json( + { + error: 'Failed to send invitation. User is already a part of the organization.', + }, + { status: 400 } + ) + } + if (pendingInvitationEmails.length > 0) { + return NextResponse.json( + { + error: + 'Failed to send invitation. A pending invitation already exists for this email.', + }, + { status: 400 } + ) + } } - if (pendingInvitationEmails.length > 0) { - return NextResponse.json( - { - error: - 'Failed to send invitation. A pending invitation already exists for this email.', + + return NextResponse.json( + { + error: 'All emails are already members or have pending invitations.', + details: { + existingMembers: existingMembersEmails, + pendingInvitations: pendingInvitationEmails, }, - { status: 400 } - ) - } + }, + { status: 400 } + ) } - return NextResponse.json( - { - error: 'All emails are already members or have pending invitations.', - details: { - existingMembers: existingMembersEmails, - pendingInvitations: pendingInvitationEmails, - }, - }, - { status: 400 } - ) - } + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days + const invitationsToCreate = emailsToInvite.map((email: string) => ({ + id: generateId(), + email, + inviterId: session.user.id, + organizationId, + role, + status: 'pending' as const, + expiresAt, + createdAt: new Date(), + })) - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days - const invitationsToCreate = emailsToInvite.map((email: string) => ({ - id: generateId(), - email, - inviterId: session.user.id, - organizationId, - role, - status: 'pending' as const, - expiresAt, - createdAt: new Date(), - })) - - await db.insert(invitation).values(invitationsToCreate) - - const workspaceInvitationIds: string[] = [] - if (isBatch && validWorkspaceInvitations.length > 0) { - for (const email of emailsToInvite) { - const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email) - for (const wsInvitation of validWorkspaceInvitations) { - const wsInvitationId = generateId() - const token = generateId() - - await db.insert(workspaceInvitation).values({ - id: wsInvitationId, - workspaceId: wsInvitation.workspaceId, - email, - inviterId: session.user.id, - role: 'member', - status: 'pending', - token, - permissions: wsInvitation.permission, - orgInvitationId: orgInviteForEmail?.id, - expiresAt, - createdAt: new Date(), - updatedAt: new Date(), - }) + await db.insert(invitation).values(invitationsToCreate) - workspaceInvitationIds.push(wsInvitationId) + const workspaceInvitationIds: string[] = [] + if (isBatch && validWorkspaceInvitations.length > 0) { + for (const email of emailsToInvite) { + const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email) + for (const wsInvitation of validWorkspaceInvitations) { + const wsInvitationId = generateId() + const token = generateId() + + await db.insert(workspaceInvitation).values({ + id: wsInvitationId, + workspaceId: wsInvitation.workspaceId, + email, + inviterId: session.user.id, + role: 'member', + status: 'pending', + token, + permissions: wsInvitation.permission, + orgInvitationId: orgInviteForEmail?.id, + expiresAt, + createdAt: new Date(), + updatedAt: new Date(), + }) + + workspaceInvitationIds.push(wsInvitationId) + } } } - } - const inviter = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) + const inviter = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) - for (const email of emailsToInvite) { - const orgInvitation = invitationsToCreate.find((inv) => inv.email === email) - if (!orgInvitation) continue + for (const email of emailsToInvite) { + const orgInvitation = invitationsToCreate.find((inv) => inv.email === email) + if (!orgInvitation) continue + + let emailResult + if (isBatch && validWorkspaceInvitations.length > 0) { + const workspaceDetails = await db + .select({ + id: workspace.id, + name: workspace.name, + }) + .from(workspace) + .where( + inArray( + workspace.id, + validWorkspaceInvitations.map((w) => w.workspaceId) + ) + ) - let emailResult - if (isBatch && validWorkspaceInvitations.length > 0) { - const workspaceDetails = await db - .select({ - id: workspace.id, - name: workspace.name, + const workspaceInvitationsWithNames = validWorkspaceInvitations.map((wsInv) => ({ + workspaceId: wsInv.workspaceId, + workspaceName: + workspaceDetails.find((w) => w.id === wsInv.workspaceId)?.name || 'Unknown Workspace', + permission: wsInv.permission, + })) + + const emailHtml = await renderBatchInvitationEmail( + inviter[0]?.name || 'Someone', + organizationEntry[0]?.name || 'organization', + role, + workspaceInvitationsWithNames, + `${getBaseUrl()}/invite/${orgInvitation.id}` + ) + + emailResult = await sendEmail({ + to: email, + subject: getEmailSubject('batch-invitation'), + html: emailHtml, + emailType: 'transactional', }) - .from(workspace) - .where( - inArray( - workspace.id, - validWorkspaceInvitations.map((w) => w.workspaceId) - ) + } else { + const emailHtml = await renderInvitationEmail( + inviter[0]?.name || 'Someone', + organizationEntry[0]?.name || 'organization', + `${getBaseUrl()}/invite/${orgInvitation.id}` ) - const workspaceInvitationsWithNames = validWorkspaceInvitations.map((wsInv) => ({ - workspaceId: wsInv.workspaceId, - workspaceName: - workspaceDetails.find((w) => w.id === wsInv.workspaceId)?.name || 'Unknown Workspace', - permission: wsInv.permission, - })) - - const emailHtml = await renderBatchInvitationEmail( - inviter[0]?.name || 'Someone', - organizationEntry[0]?.name || 'organization', - role, - workspaceInvitationsWithNames, - `${getBaseUrl()}/invite/${orgInvitation.id}` - ) + emailResult = await sendEmail({ + to: email, + subject: getEmailSubject('invitation'), + html: emailHtml, + emailType: 'transactional', + }) + } - emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('batch-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - } else { - const emailHtml = await renderInvitationEmail( - inviter[0]?.name || 'Someone', - organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/${orgInvitation.id}` - ) + if (!emailResult.success) { + logger.error('Failed to send invitation email', { + email, + error: emailResult.message, + }) + } + } - emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('invitation'), - html: emailHtml, - emailType: 'transactional', + logger.info('Organization invitations created', { + organizationId, + invitedBy: session.user.id, + invitationCount: invitationsToCreate.length, + emails: emailsToInvite, + role, + isBatch, + workspaceInvitationCount: workspaceInvitationIds.length, + }) + + for (const inv of invitationsToCreate) { + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: organizationEntry[0]?.name, + description: `Invited ${inv.email} to organization as ${role}`, + metadata: { invitationId: inv.id, targetEmail: inv.email, targetRole: role }, + request, }) } - if (!emailResult.success) { - logger.error('Failed to send invitation email', { - email, - error: emailResult.message, - }) + return NextResponse.json({ + success: true, + message: `${invitationsToCreate.length} invitation(s) sent successfully`, + data: { + invitationsSent: invitationsToCreate.length, + invitedEmails: emailsToInvite, + existingMembers: processedEmails.filter((email: string) => + existingEmails.includes(email) + ), + pendingInvitations: processedEmails.filter((email: string) => + pendingEmails.includes(email) + ), + invalidEmails: invitationEmails.filter( + (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid + ), + workspaceInvitations: isBatch ? validWorkspaceInvitations.length : 0, + seatInfo: { + seatsUsed: seatValidation.currentSeats + invitationsToCreate.length, + maxSeats: seatValidation.maxSeats, + availableSeats: seatValidation.availableSeats - invitationsToCreate.length, + }, + }, + }) + } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) } - } - logger.info('Organization invitations created', { - organizationId, - invitedBy: session.user.id, - invitationCount: invitationsToCreate.length, - emails: emailsToInvite, - role, - isBatch, - workspaceInvitationCount: workspaceInvitationIds.length, - }) - - for (const inv of invitationsToCreate) { - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: organizationEntry[0]?.name, - description: `Invited ${inv.email} to organization as ${role}`, - metadata: { invitationId: inv.id, targetEmail: inv.email, targetRole: role }, - request, + logger.error('Failed to create organization invitations', { + organizationId: (await params).id, + error, }) - } - return NextResponse.json({ - success: true, - message: `${invitationsToCreate.length} invitation(s) sent successfully`, - data: { - invitationsSent: invitationsToCreate.length, - invitedEmails: emailsToInvite, - existingMembers: processedEmails.filter((email: string) => existingEmails.includes(email)), - pendingInvitations: processedEmails.filter((email: string) => - pendingEmails.includes(email) - ), - invalidEmails: invitationEmails.filter( - (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid - ), - workspaceInvitations: isBatch ? validWorkspaceInvitations.length : 0, - seatInfo: { - seatsUsed: seatValidation.currentSeats + invitationsToCreate.length, - maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - invitationsToCreate.length, - }, - }, - }) - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - logger.error('Failed to create organization invitations', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) /** * DELETE /api/organizations/[id]/invitations?invitationId=... * Cancel a pending invitation */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() - const { id: organizationId } = await params - const url = new URL(request.url) - const invitationId = url.searchParams.get('invitationId') + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!invitationId) { - return NextResponse.json( - { error: 'Invitation ID is required as query parameter' }, - { status: 400 } - ) - } + const { id: organizationId } = await params + const url = new URL(request.url) + const invitationId = url.searchParams.get('invitationId') - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (!invitationId) { + return NextResponse.json( + { error: 'Invitation ID is required as query parameter' }, + { status: 400 } + ) + } - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - const result = await db - .update(invitation) - .set({ status: 'cancelled' }) - .where( - and( - eq(invitation.id, invitationId), - eq(invitation.organizationId, organizationId), - or(eq(invitation.status, 'pending'), eq(invitation.status, 'rejected')) + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } ) - ) - .returning() - - if (result.length === 0) { - return NextResponse.json( - { error: 'Invitation not found or already processed' }, - { status: 404 } - ) - } + } - await db - .update(workspaceInvitation) - .set({ status: 'cancelled' as WorkspaceInvitationStatus }) - .where(eq(workspaceInvitation.orgInvitationId, invitationId)) - - await db - .update(workspaceInvitation) - .set({ status: 'cancelled' as WorkspaceInvitationStatus }) - .where( - and( - isNull(workspaceInvitation.orgInvitationId), - eq(workspaceInvitation.email, result[0].email), - eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus), - eq(workspaceInvitation.inviterId, session.user.id) + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + const result = await db + .update(invitation) + .set({ status: 'cancelled' }) + .where( + and( + eq(invitation.id, invitationId), + eq(invitation.organizationId, organizationId), + or(eq(invitation.status, 'pending'), eq(invitation.status, 'rejected')) + ) + ) + .returning() + + if (result.length === 0) { + return NextResponse.json( + { error: 'Invitation not found or already processed' }, + { status: 404 } ) - ) - - logger.info('Organization invitation cancelled', { - organizationId, - invitationId, - cancelledBy: session.user.id, - email: result[0].email, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_REVOKED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Revoked organization invitation for ${result[0].email}`, - metadata: { invitationId, targetEmail: result[0].email }, - request, - }) - - return NextResponse.json({ - success: true, - message: 'Invitation cancelled successfully', - }) - } catch (error) { - logger.error('Failed to cancel organization invitation', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + await db + .update(workspaceInvitation) + .set({ status: 'cancelled' as WorkspaceInvitationStatus }) + .where(eq(workspaceInvitation.orgInvitationId, invitationId)) + + await db + .update(workspaceInvitation) + .set({ status: 'cancelled' as WorkspaceInvitationStatus }) + .where( + and( + isNull(workspaceInvitation.orgInvitationId), + eq(workspaceInvitation.email, result[0].email), + eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus), + eq(workspaceInvitation.inviterId, session.user.id) + ) + ) + + logger.info('Organization invitation cancelled', { + organizationId, + invitationId, + cancelledBy: session.user.id, + email: result[0].email, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_REVOKED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Revoked organization invitation for ${result[0].email}`, + metadata: { invitationId, targetEmail: result[0].email }, + request, + }) + + return NextResponse.json({ + success: true, + message: 'Invitation cancelled successfully', + }) + } catch (error) { + logger.error('Failed to cancel organization invitation', { + organizationId: (await params).id, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 3d850d1f971..72b83b06757 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserUsageData } from '@/lib/billing/core/usage' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationMemberAPI') @@ -21,357 +22,363 @@ const updateMemberSchema = z.object({ * GET /api/organizations/[id]/members/[memberId] * Get individual organization member details */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string; memberId: string }> } -) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: organizationId, memberId } = await params - const url = new URL(request.url) - const includeUsage = url.searchParams.get('include') === 'usage' - - const userMember = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (userMember.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - const userRole = userMember[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) - - const memberQuery = db - .select({ - id: member.id, - userId: member.userId, - organizationId: member.organizationId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .limit(1) - - const memberEntry = await memberQuery +export const GET = withRouteHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string; memberId: string }> } + ) => { + try { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (memberEntry.length === 0) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } + const { id: organizationId, memberId } = await params + const url = new URL(request.url) + const includeUsage = url.searchParams.get('include') === 'usage' - const canViewDetails = hasAdminAccess || session.user.id === memberId + const userMember = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (!canViewDetails) { - return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) - } + if (userMember.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - let memberData = memberEntry[0] + const userRole = userMember[0].role + const hasAdminAccess = ['owner', 'admin'].includes(userRole) - if (includeUsage && hasAdminAccess) { - const usageData = await db + const memberQuery = db .select({ - currentPeriodCost: userStats.currentPeriodCost, - currentUsageLimit: userStats.currentUsageLimit, - usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, - lastPeriodCost: userStats.lastPeriodCost, + id: member.id, + userId: member.userId, + organizationId: member.organizationId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, }) - .from(userStats) - .where(eq(userStats.userId, memberId)) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) .limit(1) - const computed = await getUserUsageData(memberId) - - if (usageData.length > 0) { - memberData = { - ...memberData, - usage: { - ...usageData[0], - billingPeriodStart: computed.billingPeriodStart, - billingPeriodEnd: computed.billingPeriodEnd, - }, - } as typeof memberData & { - usage: (typeof usageData)[0] & { - billingPeriodStart: Date | null - billingPeriodEnd: Date | null + const memberEntry = await memberQuery + + if (memberEntry.length === 0) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + const canViewDetails = hasAdminAccess || session.user.id === memberId + + if (!canViewDetails) { + return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) + } + + let memberData = memberEntry[0] + + if (includeUsage && hasAdminAccess) { + const usageData = await db + .select({ + currentPeriodCost: userStats.currentPeriodCost, + currentUsageLimit: userStats.currentUsageLimit, + usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, + lastPeriodCost: userStats.lastPeriodCost, + }) + .from(userStats) + .where(eq(userStats.userId, memberId)) + .limit(1) + + const computed = await getUserUsageData(memberId) + + if (usageData.length > 0) { + memberData = { + ...memberData, + usage: { + ...usageData[0], + billingPeriodStart: computed.billingPeriodStart, + billingPeriodEnd: computed.billingPeriodEnd, + }, + } as typeof memberData & { + usage: (typeof usageData)[0] & { + billingPeriodStart: Date | null + billingPeriodEnd: Date | null + } } } } - } - return NextResponse.json({ - success: true, - data: memberData, - userRole, - hasAdminAccess, - }) - } catch (error) { - logger.error('Failed to get organization member', { - organizationId: (await params).id, - memberId: (await params).memberId, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: memberData, + userRole, + hasAdminAccess, + }) + } catch (error) { + logger.error('Failed to get organization member', { + organizationId: (await params).id, + memberId: (await params).memberId, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * PUT /api/organizations/[id]/members/[memberId] * Update organization member role */ -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ id: string; memberId: string }> } -) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const PUT = withRouteHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string; memberId: string }> } + ) => { + try { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId, memberId } = await params - const body = await request.json() + const { id: organizationId, memberId } = await params + const body = await request.json() - const validation = updateMemberSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const validation = updateMemberSchema.safeParse(body) + if (!validation.success) { + const firstError = validation.error.errors[0] + return NextResponse.json({ error: firstError.message }, { status: 400 }) + } - const { role } = validation.data + const { role } = validation.data - const userMember = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + const userMember = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (userMember.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (userMember.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - if (!['owner', 'admin'].includes(userMember[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + if (!['owner', 'admin'].includes(userMember[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - const targetMember = await db - .select({ - id: member.id, - role: member.role, - userId: member.userId, - email: user.email, - name: user.name, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .limit(1) + const targetMember = await db + .select({ + id: member.id, + role: member.role, + userId: member.userId, + email: user.email, + name: user.name, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) + .limit(1) - if (targetMember.length === 0) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } + if (targetMember.length === 0) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } - if (targetMember[0].role === 'owner') { - return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 }) - } + if (targetMember[0].role === 'owner') { + return NextResponse.json({ error: 'Cannot change owner role' }, { status: 400 }) + } - if (role === 'admin' && userMember[0].role !== 'owner') { - return NextResponse.json( - { error: 'Only owners can promote members to admin' }, - { status: 403 } - ) - } + if (role === 'admin' && userMember[0].role !== 'owner') { + return NextResponse.json( + { error: 'Only owners can promote members to admin' }, + { status: 403 } + ) + } - if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') { - return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 }) - } + if (targetMember[0].role === 'admin' && userMember[0].role !== 'owner') { + return NextResponse.json({ error: 'Only owners can change admin roles' }, { status: 403 }) + } - const updatedMember = await db - .update(member) - .set({ role }) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .returning() + const updatedMember = await db + .update(member) + .set({ role }) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) + .returning() - if (updatedMember.length === 0) { - return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 }) - } + if (updatedMember.length === 0) { + return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 }) + } - logger.info('Organization member role updated', { - organizationId, - memberId, - newRole: role, - updatedBy: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Changed role for member ${memberId} to ${role}`, - metadata: { - targetUserId: memberId, - targetEmail: targetMember[0].email ?? undefined, - targetName: targetMember[0].name ?? undefined, - changes: [{ field: 'role', from: targetMember[0].role, to: role }], - }, - request, - }) - - return NextResponse.json({ - success: true, - message: 'Member role updated successfully', - data: { - id: updatedMember[0].id, - userId: updatedMember[0].userId, - role: updatedMember[0].role, + logger.info('Organization member role updated', { + organizationId, + memberId, + newRole: role, updatedBy: session.user.id, - }, - }) - } catch (error) { - logger.error('Failed to update organization member role', { - organizationId: (await params).id, - memberId: (await params).memberId, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Changed role for member ${memberId} to ${role}`, + metadata: { + targetUserId: memberId, + targetEmail: targetMember[0].email ?? undefined, + targetName: targetMember[0].name ?? undefined, + changes: [{ field: 'role', from: targetMember[0].role, to: role }], + }, + request, + }) + + return NextResponse.json({ + success: true, + message: 'Member role updated successfully', + data: { + id: updatedMember[0].id, + userId: updatedMember[0].userId, + role: updatedMember[0].role, + updatedBy: session.user.id, + }, + }) + } catch (error) { + logger.error('Failed to update organization member role', { + organizationId: (await params).id, + memberId: (await params).memberId, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * DELETE /api/organizations/[id]/members/[memberId] * Remove member from organization */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string; memberId: string }> } -) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string; memberId: string }> } + ) => { + try { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId, memberId: targetUserId } = await params + const { id: organizationId, memberId: targetUserId } = await params - const userMember = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + const userMember = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (userMember.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (userMember.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - const canRemoveMembers = - ['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId + const canRemoveMembers = + ['owner', 'admin'].includes(userMember[0].role) || session.user.id === targetUserId - if (!canRemoveMembers) { - return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) - } + if (!canRemoveMembers) { + return NextResponse.json({ error: 'Forbidden - Insufficient permissions' }, { status: 403 }) + } - const targetMember = await db - .select({ id: member.id, role: member.role, email: user.email, name: user.name }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId))) - .limit(1) + const targetMember = await db + .select({ id: member.id, role: member.role, email: user.email, name: user.name }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, targetUserId))) + .limit(1) - if (targetMember.length === 0) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } + if (targetMember.length === 0) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } - const result = await removeUserFromOrganization({ - userId: targetUserId, - organizationId, - memberId: targetMember[0].id, - }) + const result = await removeUserFromOrganization({ + userId: targetUserId, + organizationId, + memberId: targetMember[0].id, + }) - if (!result.success) { - if (result.error === 'Cannot remove organization owner') { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - if (result.error === 'Member not found') { - return NextResponse.json({ error: result.error }, { status: 404 }) + if (!result.success) { + if (result.error === 'Cannot remove organization owner') { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + if (result.error === 'Member not found') { + return NextResponse.json({ error: result.error }, { status: 404 }) + } + return NextResponse.json({ error: result.error }, { status: 500 }) } - return NextResponse.json({ error: result.error }, { status: 500 }) - } - logger.info('Organization member removed', { - organizationId, - removedMemberId: targetUserId, - removedBy: session.user.id, - wasSelfRemoval: session.user.id === targetUserId, - billingActions: result.billingActions, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_MEMBER_REMOVED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: - session.user.id === targetUserId - ? 'Left the organization' - : `Removed member ${targetUserId} from organization`, - metadata: { - targetUserId, - targetEmail: targetMember[0].email ?? undefined, - targetName: targetMember[0].name ?? undefined, - wasSelfRemoval: session.user.id === targetUserId, - }, - request, - }) - - return NextResponse.json({ - success: true, - message: - session.user.id === targetUserId - ? 'You have left the organization' - : 'Member removed successfully', - data: { + logger.info('Organization member removed', { + organizationId, removedMemberId: targetUserId, removedBy: session.user.id, - removedAt: new Date().toISOString(), - }, - }) - } catch (error) { - logger.error('Failed to remove organization member', { - organizationId: (await params).id, - memberId: (await params).memberId, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + wasSelfRemoval: session.user.id === targetUserId, + billingActions: result.billingActions, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: + session.user.id === targetUserId + ? 'Left the organization' + : `Removed member ${targetUserId} from organization`, + metadata: { + targetUserId, + targetEmail: targetMember[0].email ?? undefined, + targetName: targetMember[0].name ?? undefined, + wasSelfRemoval: session.user.id === targetUserId, + }, + request, + }) + + return NextResponse.json({ + success: true, + message: + session.user.id === targetUserId + ? 'You have left the organization' + : 'Member removed successfully', + data: { + removedMemberId: targetUserId, + removedBy: session.user.id, + removedAt: new Date().toISOString(), + }, + }) + } catch (error) { + logger.error('Failed to remove organization member', { + organizationId: (await params).id, + memberId: (await params).memberId, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 3b15d34848e..f7eb81d5441 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -10,6 +10,7 @@ import { getUserUsageData } from '@/lib/billing/core/usage' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -19,53 +20,38 @@ const logger = createLogger('OrganizationMembersAPI') * GET /api/organizations/[id]/members * Get organization members with optional usage data */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const GET = 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 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId } = await params - const url = new URL(request.url) - const includeUsage = url.searchParams.get('include') === 'usage' - - // Verify user has access to this organization - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + const { id: organizationId } = await params + const url = new URL(request.url) + const includeUsage = url.searchParams.get('include') === 'usage' - const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) - - // Get organization members - const query = db - .select({ - id: member.id, - userId: member.userId, - organizationId: member.organizationId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(eq(member.organizationId, organizationId)) + // Verify user has access to this organization + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + const userRole = memberEntry[0].role + const hasAdminAccess = ['owner', 'admin'].includes(userRole) - // Include usage data if requested and user has admin access - if (includeUsage && hasAdminAccess) { - const base = await db + // Get organization members + const query = db .select({ id: member.id, userId: member.userId, @@ -74,247 +60,266 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ createdAt: member.createdAt, userName: user.name, userEmail: user.email, - currentPeriodCost: userStats.currentPeriodCost, - currentUsageLimit: userStats.currentUsageLimit, - usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, }) .from(member) .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(userStats, eq(user.id, userStats.userId)) .where(eq(member.organizationId, organizationId)) - const membersWithUsage = await Promise.all( - base.map(async (row) => { - const usage = await getUserUsageData(row.userId) - return { - ...row, - billingPeriodStart: usage.billingPeriodStart, - billingPeriodEnd: usage.billingPeriodEnd, - } + // Include usage data if requested and user has admin access + if (includeUsage && hasAdminAccess) { + const base = await db + .select({ + id: member.id, + userId: member.userId, + organizationId: member.organizationId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, + currentPeriodCost: userStats.currentPeriodCost, + currentUsageLimit: userStats.currentUsageLimit, + usageLimitUpdatedAt: userStats.usageLimitUpdatedAt, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .leftJoin(userStats, eq(user.id, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + + const membersWithUsage = await Promise.all( + base.map(async (row) => { + const usage = await getUserUsageData(row.userId) + return { + ...row, + billingPeriodStart: usage.billingPeriodStart, + billingPeriodEnd: usage.billingPeriodEnd, + } + }) + ) + + return NextResponse.json({ + success: true, + data: membersWithUsage, + total: membersWithUsage.length, + userRole, + hasAdminAccess, }) - ) + } + + const members = await query return NextResponse.json({ success: true, - data: membersWithUsage, - total: membersWithUsage.length, + data: members, + total: members.length, userRole, hasAdminAccess, }) - } + } catch (error) { + logger.error('Failed to get organization members', { + organizationId: (await params).id, + error, + }) - const members = await query - - return NextResponse.json({ - success: true, - data: members, - total: members.length, - userRole, - hasAdminAccess, - }) - } catch (error) { - logger.error('Failed to get organization members', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * POST /api/organizations/[id]/members * Invite new member to organization */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const POST = 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 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId } = await params - const { email, role = 'member' } = await request.json() + const { id: organizationId } = await params + const { email, role = 'member' } = await request.json() - // Validate input - if (!email) { - return NextResponse.json({ error: 'Email is required' }, { status: 400 }) - } + // Validate input + if (!email) { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }) + } - if (!['admin', 'member'].includes(role)) { - return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) - } + if (!['admin', 'member'].includes(role)) { + return NextResponse.json({ error: 'Invalid role' }, { status: 400 }) + } - // Validate and normalize email - const normalizedEmail = email.trim().toLowerCase() - const validation = quickValidateEmail(normalizedEmail) - if (!validation.isValid) { - return NextResponse.json( - { error: validation.reason || 'Invalid email format' }, - { status: 400 } - ) - } + // Validate and normalize email + const normalizedEmail = email.trim().toLowerCase() + const validation = quickValidateEmail(normalizedEmail) + if (!validation.isValid) { + return NextResponse.json( + { error: validation.reason || 'Invalid email format' }, + { status: 400 } + ) + } - // Verify user has admin access - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + // Verify user has admin access + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - // Check seat availability - const seatValidation = await validateSeatAvailability(organizationId, 1) - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: `Cannot invite member. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`, - details: seatValidation, - }, - { status: 400 } - ) - } + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - // Check if user is already a member - const existingUser = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, normalizedEmail)) - .limit(1) + // Check seat availability + const seatValidation = await validateSeatAvailability(organizationId, 1) + if (!seatValidation.canInvite) { + return NextResponse.json( + { + error: `Cannot invite member. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`, + details: seatValidation, + }, + { status: 400 } + ) + } - if (existingUser.length > 0) { - const existingMember = await db + // Check if user is already a member + const existingUser = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, normalizedEmail)) + .limit(1) + + if (existingUser.length > 0) { + const existingMember = await db + .select() + .from(member) + .where( + and(eq(member.organizationId, organizationId), eq(member.userId, existingUser[0].id)) + ) + .limit(1) + + if (existingMember.length > 0) { + return NextResponse.json( + { error: 'User is already a member of this organization' }, + { status: 400 } + ) + } + } + + // Check for existing pending invitation + const existingInvitation = await db .select() - .from(member) + .from(invitation) .where( - and(eq(member.organizationId, organizationId), eq(member.userId, existingUser[0].id)) + and( + eq(invitation.organizationId, organizationId), + eq(invitation.email, normalizedEmail), + eq(invitation.status, 'pending') + ) ) .limit(1) - if (existingMember.length > 0) { + if (existingInvitation.length > 0) { return NextResponse.json( - { error: 'User is already a member of this organization' }, + { error: 'Pending invitation already exists for this email' }, { status: 400 } ) } - } - - // Check for existing pending invitation - const existingInvitation = await db - .select() - .from(invitation) - .where( - and( - eq(invitation.organizationId, organizationId), - eq(invitation.email, normalizedEmail), - eq(invitation.status, 'pending') - ) - ) - .limit(1) - if (existingInvitation.length > 0) { - return NextResponse.json( - { error: 'Pending invitation already exists for this email' }, - { status: 400 } - ) - } + // Create invitation + const invitationId = generateId() + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry - // Create invitation - const invitationId = generateId() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry - - await db.insert(invitation).values({ - id: invitationId, - email: normalizedEmail, - inviterId: session.user.id, - organizationId, - role, - status: 'pending', - expiresAt, - createdAt: new Date(), - }) - - const organizationEntry = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - const inviter = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const emailHtml = await renderInvitationEmail( - inviter[0]?.name || 'Someone', - organizationEntry[0]?.name || 'organization', - `${getBaseUrl()}/invite/organization?id=${invitationId}` - ) - - const emailResult = await sendEmail({ - to: normalizedEmail, - subject: getEmailSubject('invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (emailResult.success) { - logger.info('Member invitation sent', { + await db.insert(invitation).values({ + id: invitationId, email: normalizedEmail, + inviterId: session.user.id, organizationId, - invitationId, role, + status: 'pending', + expiresAt, + createdAt: new Date(), }) - } else { - logger.error('Failed to send invitation email', { - email: normalizedEmail, - error: emailResult.message, + + const organizationEntry = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + const inviter = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + const emailHtml = await renderInvitationEmail( + inviter[0]?.name || 'Someone', + organizationEntry[0]?.name || 'organization', + `${getBaseUrl()}/invite/organization?id=${invitationId}` + ) + + const emailResult = await sendEmail({ + to: normalizedEmail, + subject: getEmailSubject('invitation'), + html: emailHtml, + emailType: 'transactional', }) - // Don't fail the request if email fails - } - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORG_INVITATION_CREATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Invited ${normalizedEmail} to organization as ${role}`, - metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role }, - request, - }) - - return NextResponse.json({ - success: true, - message: `Invitation sent to ${normalizedEmail}`, - data: { - invitationId, - email: normalizedEmail, - role, - expiresAt, - }, - }) - } catch (error) { - logger.error('Failed to invite organization member', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + if (emailResult.success) { + logger.info('Member invitation sent', { + email: normalizedEmail, + organizationId, + invitationId, + role, + }) + } else { + logger.error('Failed to send invitation email', { + email: normalizedEmail, + error: emailResult.message, + }) + // Don't fail the request if email fails + } + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Invited ${normalizedEmail} to organization as ${role}`, + metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role }, + request, + }) + + return NextResponse.json({ + success: true, + message: `Invitation sent to ${normalizedEmail}`, + data: { + invitationId, + email: normalizedEmail, + role, + expiresAt, + }, + }) + } catch (error) { + logger.error('Failed to invite organization member', { + organizationId: (await params).id, + error, + }) + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index 03515b95a22..671be8c67b9 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -10,6 +10,7 @@ import { getOrganizationSeatAnalytics, getOrganizationSeatInfo, } from '@/lib/billing/validation/seat-management' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationAPI') @@ -31,205 +32,209 @@ const updateOrganizationSchema = z.object({ * GET /api/organizations/[id] * Get organization details including settings and seat information */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const GET = 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 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId } = await params - const url = new URL(request.url) - const includeSeats = url.searchParams.get('include') === 'seats' - - // Verify user has access to this organization - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + const { id: organizationId } = await params + const url = new URL(request.url) + const includeSeats = url.searchParams.get('include') === 'seats' + + // Verify user has access to this organization + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - // Get organization data - const organizationEntry = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + // Get organization data + const organizationEntry = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (organizationEntry.length === 0) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } + if (organizationEntry.length === 0) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - const userRole = memberEntry[0].role - const hasAdminAccess = ['owner', 'admin'].includes(userRole) - - const response: any = { - success: true, - data: { - id: organizationEntry[0].id, - name: organizationEntry[0].name, - slug: organizationEntry[0].slug, - logo: organizationEntry[0].logo, - metadata: organizationEntry[0].metadata, - createdAt: organizationEntry[0].createdAt, - updatedAt: organizationEntry[0].updatedAt, - }, - userRole, - hasAdminAccess, - } + const userRole = memberEntry[0].role + const hasAdminAccess = ['owner', 'admin'].includes(userRole) - // Include seat information if requested - if (includeSeats) { - const seatInfo = await getOrganizationSeatInfo(organizationId) - if (seatInfo) { - response.data.seats = seatInfo + const response: any = { + success: true, + data: { + id: organizationEntry[0].id, + name: organizationEntry[0].name, + slug: organizationEntry[0].slug, + logo: organizationEntry[0].logo, + metadata: organizationEntry[0].metadata, + createdAt: organizationEntry[0].createdAt, + updatedAt: organizationEntry[0].updatedAt, + }, + userRole, + hasAdminAccess, } - // Include analytics for admins - if (hasAdminAccess) { - const analytics = await getOrganizationSeatAnalytics(organizationId) - if (analytics) { - response.data.seatAnalytics = analytics + // Include seat information if requested + if (includeSeats) { + const seatInfo = await getOrganizationSeatInfo(organizationId) + if (seatInfo) { + response.data.seats = seatInfo + } + + // Include analytics for admins + if (hasAdminAccess) { + const analytics = await getOrganizationSeatAnalytics(organizationId) + if (analytics) { + response.data.seatAnalytics = analytics + } } } - } - return NextResponse.json(response) - } catch (error) { - logger.error('Failed to get organization', { - organizationId: (await params).id, - error, - }) + return NextResponse.json(response) + } catch (error) { + logger.error('Failed to get organization', { + organizationId: (await params).id, + error, + }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * PUT /api/organizations/[id] * Update organization settings (name, slug, logo) * Note: For seat updates, use PUT /api/organizations/[id]/seats instead */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() - const { id: organizationId } = await params - const body = await request.json() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const validation = updateOrganizationSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + const { id: organizationId } = await params + const body = await request.json() - const { name, slug, logo } = validation.data + const validation = updateOrganizationSchema.safeParse(body) + if (!validation.success) { + const firstError = validation.error.errors[0] + return NextResponse.json({ error: firstError.message }, { status: 400 }) + } - // Verify user has admin access - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + const { name, slug, logo } = validation.data - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + // Verify user has admin access + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - // Handle settings update - if (name !== undefined || slug !== undefined || logo !== undefined) { - // Check if slug is already taken by another organization - if (slug !== undefined) { - const existingSlug = await db - .select() - .from(organization) - .where(and(eq(organization.slug, slug), ne(organization.id, organizationId))) - .limit(1) - - if (existingSlug.length > 0) { - return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 }) - } + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) } - // Build update object with only provided fields - const updateData: any = { updatedAt: new Date() } - if (name !== undefined) updateData.name = name - if (slug !== undefined) updateData.slug = slug - if (logo !== undefined) updateData.logo = logo + // Handle settings update + if (name !== undefined || slug !== undefined || logo !== undefined) { + // Check if slug is already taken by another organization + if (slug !== undefined) { + const existingSlug = await db + .select() + .from(organization) + .where(and(eq(organization.slug, slug), ne(organization.id, organizationId))) + .limit(1) + + if (existingSlug.length > 0) { + return NextResponse.json({ error: 'This slug is already taken' }, { status: 400 }) + } + } - // Update organization - const updatedOrg = await db - .update(organization) - .set(updateData) - .where(eq(organization.id, organizationId)) - .returning() + // Build update object with only provided fields + const updateData: any = { updatedAt: new Date() } + if (name !== undefined) updateData.name = name + if (slug !== undefined) updateData.slug = slug + if (logo !== undefined) updateData.logo = logo + + // Update organization + const updatedOrg = await db + .update(organization) + .set(updateData) + .where(eq(organization.id, organizationId)) + .returning() + + if (updatedOrg.length === 0) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - if (updatedOrg.length === 0) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + logger.info('Organization settings updated', { + organizationId, + updatedBy: session.user.id, + changes: { name, slug, logo }, + }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: updatedOrg[0].name, + description: `Updated organization settings`, + metadata: { changes: { name, slug, logo } }, + request, + }) + + return NextResponse.json({ + success: true, + message: 'Organization updated successfully', + data: { + id: updatedOrg[0].id, + name: updatedOrg[0].name, + slug: updatedOrg[0].slug, + logo: updatedOrg[0].logo, + updatedAt: updatedOrg[0].updatedAt, + }, + }) } - logger.info('Organization settings updated', { - organizationId, - updatedBy: session.user.id, - changes: { name, slug, logo }, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.ORGANIZATION_UPDATED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: updatedOrg[0].name, - description: `Updated organization settings`, - metadata: { changes: { name, slug, logo } }, - request, + return NextResponse.json({ error: 'No valid fields provided for update' }, { status: 400 }) + } catch (error) { + logger.error('Failed to update organization', { + organizationId: (await params).id, + error, }) - return NextResponse.json({ - success: true, - message: 'Organization updated successfully', - data: { - id: updatedOrg[0].id, - name: updatedOrg[0].name, - slug: updatedOrg[0].slug, - logo: updatedOrg[0].logo, - updatedAt: updatedOrg[0].updatedAt, - }, - }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - return NextResponse.json({ error: 'No valid fields provided for update' }, { status: 400 }) - } catch (error) { - logger.error('Failed to update organization', { - organizationId: (await params).id, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) // DELETE method removed - organization deletion not implemented // If deletion is needed in the future, it should be implemented with proper diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index 6a2be6238c0..a97e4e293c5 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -14,6 +14,7 @@ import { USABLE_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationSeatsAPI') @@ -26,250 +27,252 @@ const updateSeatsSchema = z.object({ * Update organization seat count using Stripe's subscription.update API. * This is the recommended approach for per-seat billing changes. */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() +export const PUT = 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 }) - } - - if (!isBillingEnabled) { - return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 }) - } - - const { id: organizationId } = await params - const body = await request.json() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const validation = updateSeatsSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { status: 400 }) - } + if (!isBillingEnabled) { + return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 }) + } - const { seats: newSeatCount } = validation.data + const { id: organizationId } = await params + const body = await request.json() - // Verify user has admin access to this organization - const memberEntry = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + const validation = updateSeatsSchema.safeParse(body) + if (!validation.success) { + const firstError = validation.error.errors[0] + return NextResponse.json({ error: firstError.message }, { status: 400 }) + } - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + const { seats: newSeatCount } = validation.data - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + // Verify user has admin access to this organization + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - // Get the organization's subscription - const subscriptionRecord = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } ) - ) - .limit(1) - - if (subscriptionRecord.length === 0) { - return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) - } + } - const orgSubscription = subscriptionRecord[0] + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - if (await isOrganizationBillingBlocked(organizationId)) { - return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) - } + // Get the organization's subscription + const subscriptionRecord = await db + .select() + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES) + ) + ) + .limit(1) - // Only team plans support seat changes (not enterprise - those are handled manually) - if (!isTeam(orgSubscription.plan)) { - return NextResponse.json( - { error: 'Seat changes are only available for Team plans' }, - { status: 400 } - ) - } + if (subscriptionRecord.length === 0) { + return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) + } - if (!orgSubscription.stripeSubscriptionId) { - return NextResponse.json( - { error: 'No Stripe subscription found for this organization' }, - { status: 400 } - ) - } + const orgSubscription = subscriptionRecord[0] - // Validate that we're not reducing below current member count - const memberCount = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, organizationId)) + if (await isOrganizationBillingBlocked(organizationId)) { + return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) + } - if (newSeatCount < memberCount.length) { - return NextResponse.json( - { - error: `Cannot reduce seats below current member count (${memberCount.length})`, - currentMembers: memberCount.length, - }, - { status: 400 } - ) - } + // Only team plans support seat changes (not enterprise - those are handled manually) + if (!isTeam(orgSubscription.plan)) { + return NextResponse.json( + { error: 'Seat changes are only available for Team plans' }, + { status: 400 } + ) + } - const currentSeats = orgSubscription.seats || 1 + if (!orgSubscription.stripeSubscriptionId) { + return NextResponse.json( + { error: 'No Stripe subscription found for this organization' }, + { status: 400 } + ) + } - // If no change, return early - if (newSeatCount === currentSeats) { - return NextResponse.json({ - success: true, - message: 'No change in seat count', - data: { - seats: currentSeats, - stripeSubscriptionId: orgSubscription.stripeSubscriptionId, - }, - }) - } + // Validate that we're not reducing below current member count + const memberCount = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, organizationId)) - const stripe = requireStripeClient() + if (newSeatCount < memberCount.length) { + return NextResponse.json( + { + error: `Cannot reduce seats below current member count (${memberCount.length})`, + currentMembers: memberCount.length, + }, + { status: 400 } + ) + } - // Get the Stripe subscription to find the subscription item ID - const stripeSubscription = await stripe.subscriptions.retrieve( - orgSubscription.stripeSubscriptionId - ) + const currentSeats = orgSubscription.seats || 1 - if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { - return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) - } + // If no change, return early + if (newSeatCount === currentSeats) { + return NextResponse.json({ + success: true, + message: 'No change in seat count', + data: { + seats: currentSeats, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + }, + }) + } - // Find the subscription item (there should be only one for team plans) - const subscriptionItem = stripeSubscription.items.data[0] + const stripe = requireStripeClient() - if (!subscriptionItem) { - return NextResponse.json( - { error: 'No subscription item found in Stripe subscription' }, - { status: 500 } + // Get the Stripe subscription to find the subscription item ID + const stripeSubscription = await stripe.subscriptions.retrieve( + orgSubscription.stripeSubscriptionId ) - } - logger.info('Updating Stripe subscription quantity', { - organizationId, - stripeSubscriptionId: orgSubscription.stripeSubscriptionId, - subscriptionItemId: subscriptionItem.id, - currentSeats, - newSeatCount, - userId: session.user.id, - }) - - // Update the subscription item quantity using Stripe's recommended approach - // This will automatically prorate the billing - const updatedSubscription = await stripe.subscriptions.update( - orgSubscription.stripeSubscriptionId, - { - items: [ - { - id: subscriptionItem.id, - quantity: newSeatCount, - }, - ], - proration_behavior: 'always_invoice', + if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { + return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) } - ) - - // Update our local database to reflect the change - // Note: This will also be updated via webhook, but we update immediately for UX - await db - .update(subscription) - .set({ - seats: newSeatCount, - }) - .where(eq(subscription.id, orgSubscription.id)) - // Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum) - const { basePrice } = getPlanPricing(orgSubscription.plan) - const newMinimumLimit = newSeatCount * basePrice + // Find the subscription item (there should be only one for team plans) + const subscriptionItem = stripeSubscription.items.data[0] - const orgData = await db - .select({ orgUsageLimit: organization.orgUsageLimit }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + if (!subscriptionItem) { + return NextResponse.json( + { error: 'No subscription item found in Stripe subscription' }, + { status: 500 } + ) + } - const currentOrgLimit = - orgData.length > 0 && orgData[0].orgUsageLimit - ? Number.parseFloat(orgData[0].orgUsageLimit) - : 0 + logger.info('Updating Stripe subscription quantity', { + organizationId, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + subscriptionItemId: subscriptionItem.id, + currentSeats, + newSeatCount, + userId: session.user.id, + }) + + // Update the subscription item quantity using Stripe's recommended approach + // This will automatically prorate the billing + const updatedSubscription = await stripe.subscriptions.update( + orgSubscription.stripeSubscriptionId, + { + items: [ + { + id: subscriptionItem.id, + quantity: newSeatCount, + }, + ], + proration_behavior: 'always_invoice', + } + ) - // Update if new minimum is higher than current limit - if (newMinimumLimit > currentOrgLimit) { + // Update our local database to reflect the change + // Note: This will also be updated via webhook, but we update immediately for UX await db - .update(organization) + .update(subscription) .set({ - orgUsageLimit: newMinimumLimit.toFixed(2), - updatedAt: new Date(), + seats: newSeatCount, }) + .where(eq(subscription.id, orgSubscription.id)) + + // Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum) + const { basePrice } = getPlanPricing(orgSubscription.plan) + const newMinimumLimit = newSeatCount * basePrice + + const orgData = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) .where(eq(organization.id, organizationId)) + .limit(1) + + const currentOrgLimit = + orgData.length > 0 && orgData[0].orgUsageLimit + ? Number.parseFloat(orgData[0].orgUsageLimit) + : 0 + + // Update if new minimum is higher than current limit + if (newMinimumLimit > currentOrgLimit) { + await db + .update(organization) + .set({ + orgUsageLimit: newMinimumLimit.toFixed(2), + updatedAt: new Date(), + }) + .where(eq(organization.id, organizationId)) + + logger.info('Updated organization usage limit for seat change', { + organizationId, + newSeatCount, + newMinimumLimit, + previousLimit: currentOrgLimit, + }) + } - logger.info('Updated organization usage limit for seat change', { + logger.info('Successfully updated seat count', { organizationId, - newSeatCount, - newMinimumLimit, - previousLimit: currentOrgLimit, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + oldSeats: currentSeats, + newSeats: newSeatCount, + updatedBy: session.user.id, + prorationBehavior: 'always_invoice', }) - } - logger.info('Successfully updated seat count', { - organizationId, - stripeSubscriptionId: orgSubscription.stripeSubscriptionId, - oldSeats: currentSeats, - newSeats: newSeatCount, - updatedBy: session.user.id, - prorationBehavior: 'always_invoice', - }) - - return NextResponse.json({ - success: true, - message: - newSeatCount > currentSeats - ? `Added ${newSeatCount - currentSeats} seat(s). Your billing has been adjusted.` - : `Removed ${currentSeats - newSeatCount} seat(s). You'll receive a prorated credit.`, - data: { - seats: newSeatCount, - previousSeats: currentSeats, - stripeSubscriptionId: updatedSubscription.id, - stripeStatus: updatedSubscription.status, - }, - }) - } catch (error) { - const { id: organizationId } = await params - - // Handle Stripe-specific errors - if (error instanceof Error && 'type' in error) { - const stripeError = error as any - logger.error('Stripe error updating seats', { - organizationId, - type: stripeError.type, - code: stripeError.code, - message: stripeError.message, + return NextResponse.json({ + success: true, + message: + newSeatCount > currentSeats + ? `Added ${newSeatCount - currentSeats} seat(s). Your billing has been adjusted.` + : `Removed ${currentSeats - newSeatCount} seat(s). You'll receive a prorated credit.`, + data: { + seats: newSeatCount, + previousSeats: currentSeats, + stripeSubscriptionId: updatedSubscription.id, + stripeStatus: updatedSubscription.status, + }, }) - - return NextResponse.json( - { - error: stripeError.message || 'Failed to update seats in Stripe', + } catch (error) { + const { id: organizationId } = await params + + // Handle Stripe-specific errors + if (error instanceof Error && 'type' in error) { + const stripeError = error as any + logger.error('Stripe error updating seats', { + organizationId, + type: stripeError.type, code: stripeError.code, - }, - { status: 400 } - ) - } + message: stripeError.message, + }) + + return NextResponse.json( + { + error: stripeError.message || 'Failed to update seats in Stripe', + code: stripeError.code, + }, + { status: 400 } + ) + } - logger.error('Failed to update organization seats', { - organizationId, - error, - }) + logger.error('Failed to update organization seats', { + organizationId, + error, + }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 5803f85dc25..262a61e43ee 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -6,10 +6,11 @@ import { NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { createOrganizationForTeamPlan } from '@/lib/billing/organization' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationsAPI') -export async function GET() { +export const GET = withRouteHandler(async () => { try { const session = await getSession() @@ -50,9 +51,9 @@ export async function GET() { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const session = await getSession() @@ -147,4 +148,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts b/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts index f0be70d26b3..417e55a443d 100644 --- a/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/members/bulk/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('PermissionGroupBulkMembers') @@ -38,130 +39,132 @@ const bulkAddSchema = z.object({ addAllOrgMembers: z.boolean().optional(), }) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id } = await params + const { id } = await params - try { - const hasAccess = await hasAccessControlAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Access Control is an Enterprise feature' }, - { status: 403 } - ) - } + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } - const result = await getPermissionGroupWithAccess(id, session.user.id) + const result = await getPermissionGroupWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - const body = await req.json() - const { userIds, addAllOrgMembers } = bulkAddSchema.parse(body) - - let targetUserIds: string[] = [] - - if (addAllOrgMembers) { - const orgMembers = await db - .select({ userId: member.userId }) - .from(member) - .where(eq(member.organizationId, result.group.organizationId)) - - targetUserIds = orgMembers.map((m) => m.userId) - } else if (userIds && userIds.length > 0) { - const validMembers = await db - .select({ userId: member.userId }) - .from(member) - .where( - and( - eq(member.organizationId, result.group.organizationId), - inArray(member.userId, userIds) + const body = await req.json() + const { userIds, addAllOrgMembers } = bulkAddSchema.parse(body) + + let targetUserIds: string[] = [] + + if (addAllOrgMembers) { + const orgMembers = await db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, result.group.organizationId)) + + targetUserIds = orgMembers.map((m) => m.userId) + } else if (userIds && userIds.length > 0) { + const validMembers = await db + .select({ userId: member.userId }) + .from(member) + .where( + and( + eq(member.organizationId, result.group.organizationId), + inArray(member.userId, userIds) + ) ) - ) - - targetUserIds = validMembers.map((m) => m.userId) - } - if (targetUserIds.length === 0) { - return NextResponse.json({ added: 0, moved: 0 }) - } + targetUserIds = validMembers.map((m) => m.userId) + } - const existingMemberships = await db - .select({ - id: permissionGroupMember.id, - userId: permissionGroupMember.userId, - permissionGroupId: permissionGroupMember.permissionGroupId, - }) - .from(permissionGroupMember) - .where(inArray(permissionGroupMember.userId, targetUserIds)) + if (targetUserIds.length === 0) { + return NextResponse.json({ added: 0, moved: 0 }) + } - const alreadyInThisGroup = new Set( - existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId) - ) - const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid)) + const existingMemberships = await db + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + permissionGroupId: permissionGroupMember.permissionGroupId, + }) + .from(permissionGroupMember) + .where(inArray(permissionGroupMember.userId, targetUserIds)) + + const alreadyInThisGroup = new Set( + existingMemberships.filter((m) => m.permissionGroupId === id).map((m) => m.userId) + ) + const usersToAdd = targetUserIds.filter((uid) => !alreadyInThisGroup.has(uid)) - if (usersToAdd.length === 0) { - return NextResponse.json({ added: 0, moved: 0 }) - } + if (usersToAdd.length === 0) { + return NextResponse.json({ added: 0, moved: 0 }) + } - const membershipsToDelete = existingMemberships.filter( - (m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId) - ) - const movedCount = membershipsToDelete.length - - await db.transaction(async (tx) => { - if (membershipsToDelete.length > 0) { - await tx.delete(permissionGroupMember).where( - inArray( - permissionGroupMember.id, - membershipsToDelete.map((m) => m.id) + const membershipsToDelete = existingMemberships.filter( + (m) => m.permissionGroupId !== id && usersToAdd.includes(m.userId) + ) + const movedCount = membershipsToDelete.length + + await db.transaction(async (tx) => { + if (membershipsToDelete.length > 0) { + await tx.delete(permissionGroupMember).where( + inArray( + permissionGroupMember.id, + membershipsToDelete.map((m) => m.id) + ) ) - ) - } + } - const newMembers = usersToAdd.map((userId) => ({ - id: generateId(), - permissionGroupId: id, - userId, - assignedBy: session.user.id, - assignedAt: new Date(), - })) + const newMembers = usersToAdd.map((userId) => ({ + id: generateId(), + permissionGroupId: id, + userId, + assignedBy: session.user.id, + assignedAt: new Date(), + })) - await tx.insert(permissionGroupMember).values(newMembers) - }) + await tx.insert(permissionGroupMember).values(newMembers) + }) - logger.info('Bulk added members to permission group', { - permissionGroupId: id, - addedCount: usersToAdd.length, - movedCount, - assignedBy: session.user.id, - }) + logger.info('Bulk added members to permission group', { + permissionGroupId: id, + addedCount: usersToAdd.length, + movedCount, + assignedBy: session.user.id, + }) - return NextResponse.json({ added: usersToAdd.length, moved: movedCount }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) - } - if ( - error instanceof Error && - error.message.includes('permission_group_member_user_id_unique') - ) { - return NextResponse.json( - { error: 'One or more users are already in a permission group' }, - { status: 409 } - ) + return NextResponse.json({ added: usersToAdd.length, moved: movedCount }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + if ( + error instanceof Error && + error.message.includes('permission_group_member_user_id_unique') + ) { + return NextResponse.json( + { error: 'One or more users are already in a permission group' }, + { status: 409 } + ) + } + logger.error('Error bulk adding members to permission group', error) + return NextResponse.json({ error: 'Failed to add members' }, { status: 500 }) } - logger.error('Error bulk adding members to permission group', error) - return NextResponse.json({ error: 'Failed to add members' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/permission-groups/[id]/members/route.ts b/apps/sim/app/api/permission-groups/[id]/members/route.ts index 5b5fdc65df7..59d6b51396e 100644 --- a/apps/sim/app/api/permission-groups/[id]/members/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/members/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('PermissionGroupMembers') @@ -35,242 +36,256 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) { return { group, role: membership.role } } -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id } = await params - const result = await getPermissionGroupWithAccess(id, session.user.id) + const { id } = await params + const result = await getPermissionGroupWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - const members = await db - .select({ - id: permissionGroupMember.id, - userId: permissionGroupMember.userId, - assignedAt: permissionGroupMember.assignedAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(permissionGroupMember) - .leftJoin(user, eq(permissionGroupMember.userId, user.id)) - .where(eq(permissionGroupMember.permissionGroupId, id)) + const members = await db + .select({ + id: permissionGroupMember.id, + userId: permissionGroupMember.userId, + assignedAt: permissionGroupMember.assignedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissionGroupMember) + .leftJoin(user, eq(permissionGroupMember.userId, user.id)) + .where(eq(permissionGroupMember.permissionGroupId, id)) - return NextResponse.json({ members }) -} + return NextResponse.json({ members }) + } +) const addMemberSchema = z.object({ userId: z.string().min(1), }) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - const { id } = await params - - try { - const hasAccess = await hasAccessControlAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Access Control is an Enterprise feature' }, - { status: 403 } - ) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const result = await getPermissionGroupWithAccess(id, session.user.id) + const { id } = await params - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } - const body = await req.json() - const { userId } = addMemberSchema.parse(body) - - const [orgMember] = await db - .select({ id: member.id, email: user.email }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId))) - .limit(1) - - if (!orgMember) { - return NextResponse.json( - { error: 'User is not a member of this organization' }, - { status: 400 } - ) - } + const result = await getPermissionGroupWithAccess(id, session.user.id) - const [existingMembership] = await db - .select({ - id: permissionGroupMember.id, - permissionGroupId: permissionGroupMember.permissionGroupId, - }) - .from(permissionGroupMember) - .where(eq(permissionGroupMember.userId, userId)) - .limit(1) - - if (existingMembership?.permissionGroupId === id) { - return NextResponse.json( - { error: 'User is already in this permission group' }, - { status: 409 } - ) - } + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - const newMember = await db.transaction(async (tx) => { - if (existingMembership) { - await tx - .delete(permissionGroupMember) - .where(eq(permissionGroupMember.id, existingMembership.id)) + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) } - const memberData = { - id: generateId(), - permissionGroupId: id, - userId, - assignedBy: session.user.id, - assignedAt: new Date(), + const body = await req.json() + const { userId } = addMemberSchema.parse(body) + + const [orgMember] = await db + .select({ id: member.id, email: user.email }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where( + and(eq(member.userId, userId), eq(member.organizationId, result.group.organizationId)) + ) + .limit(1) + + if (!orgMember) { + return NextResponse.json( + { error: 'User is not a member of this organization' }, + { status: 400 } + ) } - await tx.insert(permissionGroupMember).values(memberData) - return memberData - }) + const [existingMembership] = await db + .select({ + id: permissionGroupMember.id, + permissionGroupId: permissionGroupMember.permissionGroupId, + }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.userId, userId)) + .limit(1) + + if (existingMembership?.permissionGroupId === id) { + return NextResponse.json( + { error: 'User is already in this permission group' }, + { status: 409 } + ) + } - logger.info('Added member to permission group', { - permissionGroupId: id, - userId, - assignedBy: session.user.id, - }) + const newMember = await db.transaction(async (tx) => { + if (existingMembership) { + await tx + .delete(permissionGroupMember) + .where(eq(permissionGroupMember.id, existingMembership.id)) + } + + const memberData = { + id: generateId(), + permissionGroupId: id, + userId, + assignedBy: session.user.id, + assignedAt: new Date(), + } + + await tx.insert(permissionGroupMember).values(memberData) + return memberData + }) - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - resourceName: result.group.name, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Added member ${userId} to permission group "${result.group.name}"`, - metadata: { - targetUserId: userId, - targetEmail: orgMember.email ?? undefined, + logger.info('Added member to permission group', { permissionGroupId: id, - }, - request: req, - }) + userId, + assignedBy: session.user.id, + }) - return NextResponse.json({ member: newMember }, { status: 201 }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) - } - if ( - error instanceof Error && - error.message.includes('permission_group_member_user_id_unique') - ) { - return NextResponse.json({ error: 'User is already in a permission group' }, { status: 409 }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + resourceName: result.group.name, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Added member ${userId} to permission group "${result.group.name}"`, + metadata: { + targetUserId: userId, + targetEmail: orgMember.email ?? undefined, + permissionGroupId: id, + }, + request: req, + }) + + return NextResponse.json({ member: newMember }, { status: 201 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + if ( + error instanceof Error && + error.message.includes('permission_group_member_user_id_unique') + ) { + return NextResponse.json( + { error: 'User is already in a permission group' }, + { status: 409 } + ) + } + logger.error('Error adding member to permission group', error) + return NextResponse.json({ error: 'Failed to add member' }, { status: 500 }) } - logger.error('Error adding member to permission group', error) - return NextResponse.json({ error: 'Failed to add member' }, { status: 500 }) } -} - -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +) - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - const { id } = await params - const { searchParams } = new URL(req.url) - const memberId = searchParams.get('memberId') + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!memberId) { - return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) - } + const { id } = await params + const { searchParams } = new URL(req.url) + const memberId = searchParams.get('memberId') - try { - const hasAccess = await hasAccessControlAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Access Control is an Enterprise feature' }, - { status: 403 } - ) + if (!memberId) { + return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) } - const result = await getPermissionGroupWithAccess(id, session.user.id) + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } + const result = await getPermissionGroupWithAccess(id, session.user.id) - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - const [memberToRemove] = await db - .select({ - id: permissionGroupMember.id, - permissionGroupId: permissionGroupMember.permissionGroupId, - userId: permissionGroupMember.userId, - email: user.email, - }) - .from(permissionGroupMember) - .innerJoin(user, eq(permissionGroupMember.userId, user.id)) - .where( - and(eq(permissionGroupMember.id, memberId), eq(permissionGroupMember.permissionGroupId, id)) - ) - .limit(1) - - if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId)) + const [memberToRemove] = await db + .select({ + id: permissionGroupMember.id, + permissionGroupId: permissionGroupMember.permissionGroupId, + userId: permissionGroupMember.userId, + email: user.email, + }) + .from(permissionGroupMember) + .innerJoin(user, eq(permissionGroupMember.userId, user.id)) + .where( + and( + eq(permissionGroupMember.id, memberId), + eq(permissionGroupMember.permissionGroupId, id) + ) + ) + .limit(1) + + if (!memberToRemove) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } - logger.info('Removed member from permission group', { - permissionGroupId: id, - memberId, - userId: session.user.id, - }) + await db.delete(permissionGroupMember).where(eq(permissionGroupMember.id, memberId)) - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - resourceName: result.group.name, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`, - metadata: { - targetUserId: memberToRemove.userId, - targetEmail: memberToRemove.email ?? undefined, - memberId, + logger.info('Removed member from permission group', { permissionGroupId: id, - }, - request: req, - }) + memberId, + userId: session.user.id, + }) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error removing member from permission group', error) - return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + resourceName: result.group.name, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`, + metadata: { + targetUserId: memberToRemove.userId, + targetEmail: memberToRemove.email ?? undefined, + memberId, + permissionGroupId: id, + }, + request: req, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error removing member from permission group', error) + return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/permission-groups/[id]/route.ts b/apps/sim/app/api/permission-groups/[id]/route.ts index 9391b67d826..0b5e3b38e7b 100644 --- a/apps/sim/app/api/permission-groups/[id]/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type PermissionGroupConfig, parsePermissionGroupConfig, @@ -73,193 +74,199 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) { return { group, role: membership.role } } -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - const result = await getPermissionGroupWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - return NextResponse.json({ - permissionGroup: { - ...result.group, - config: parsePermissionGroupConfig(result.group.config), - }, - }) -} + const { id } = await params + const result = await getPermissionGroupWithAccess(id, session.user.id) -export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ + permissionGroup: { + ...result.group, + config: parsePermissionGroupConfig(result.group.config), + }, + }) } +) - const { id } = await params +export const PUT = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - try { - const hasAccess = await hasAccessControlAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Access Control is an Enterprise feature' }, - { status: 403 } - ) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const result = await getPermissionGroupWithAccess(id, session.user.id) + const { id } = await params - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + const result = await getPermissionGroupWithAccess(id, session.user.id) - const body = await req.json() - const updates = updateSchema.parse(body) + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - if (updates.name) { - const existingGroup = await db - .select({ id: permissionGroup.id }) - .from(permissionGroup) - .where( - and( - eq(permissionGroup.organizationId, result.group.organizationId), - eq(permissionGroup.name, updates.name) + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + const body = await req.json() + const updates = updateSchema.parse(body) + + if (updates.name) { + const existingGroup = await db + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.organizationId, result.group.organizationId), + eq(permissionGroup.name, updates.name) + ) ) - ) - .limit(1) + .limit(1) - if (existingGroup.length > 0 && existingGroup[0].id !== id) { - return NextResponse.json( - { error: 'A permission group with this name already exists' }, - { status: 409 } - ) + if (existingGroup.length > 0 && existingGroup[0].id !== id) { + return NextResponse.json( + { error: 'A permission group with this name already exists' }, + { status: 409 } + ) + } } - } - const currentConfig = parsePermissionGroupConfig(result.group.config) - const newConfig: PermissionGroupConfig = updates.config - ? { ...currentConfig, ...updates.config } - : currentConfig + const currentConfig = parsePermissionGroupConfig(result.group.config) + const newConfig: PermissionGroupConfig = updates.config + ? { ...currentConfig, ...updates.config } + : currentConfig + + // If setting autoAddNewMembers to true, unset it on other groups in the org first + if (updates.autoAddNewMembers === true) { + await db + .update(permissionGroup) + .set({ autoAddNewMembers: false, updatedAt: new Date() }) + .where( + and( + eq(permissionGroup.organizationId, result.group.organizationId), + eq(permissionGroup.autoAddNewMembers, true) + ) + ) + } - // If setting autoAddNewMembers to true, unset it on other groups in the org first - if (updates.autoAddNewMembers === true) { await db .update(permissionGroup) - .set({ autoAddNewMembers: false, updatedAt: new Date() }) - .where( - and( - eq(permissionGroup.organizationId, result.group.organizationId), - eq(permissionGroup.autoAddNewMembers, true) - ) - ) - } + .set({ + ...(updates.name !== undefined && { name: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.autoAddNewMembers !== undefined && { + autoAddNewMembers: updates.autoAddNewMembers, + }), + config: newConfig, + updatedAt: new Date(), + }) + .where(eq(permissionGroup.id, id)) + + const [updated] = await db + .select() + .from(permissionGroup) + .where(eq(permissionGroup.id, id)) + .limit(1) - await db - .update(permissionGroup) - .set({ - ...(updates.name !== undefined && { name: updates.name }), - ...(updates.description !== undefined && { description: updates.description }), - ...(updates.autoAddNewMembers !== undefined && { - autoAddNewMembers: updates.autoAddNewMembers, - }), - config: newConfig, - updatedAt: new Date(), + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_UPDATED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: updated.name, + description: `Updated permission group "${updated.name}"`, + request: req, }) - .where(eq(permissionGroup.id, id)) - - const [updated] = await db - .select() - .from(permissionGroup) - .where(eq(permissionGroup.id, id)) - .limit(1) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_UPDATED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: updated.name, - description: `Updated permission group "${updated.name}"`, - request: req, - }) - return NextResponse.json({ - permissionGroup: { - ...updated, - config: parsePermissionGroupConfig(updated.config), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + return NextResponse.json({ + permissionGroup: { + ...updated, + config: parsePermissionGroupConfig(updated.config), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error updating permission group', error) + return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 }) } - logger.error('Error updating permission group', error) - return NextResponse.json({ error: 'Failed to update permission group' }, { status: 500 }) } -} +) -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const session = await getSession() +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id } = await params + const { id } = await params - try { - const hasAccess = await hasAccessControlAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Access Control is an Enterprise feature' }, - { status: 403 } - ) - } + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { + return NextResponse.json( + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } + ) + } - const result = await getPermissionGroupWithAccess(id, session.user.id) + const result = await getPermissionGroupWithAccess(id, session.user.id) - if (!result) { - return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) - } + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - await db.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id)) - await db.delete(permissionGroup).where(eq(permissionGroup.id, id)) - - logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.PERMISSION_GROUP_DELETED, - resourceType: AuditResourceType.PERMISSION_GROUP, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.group.name, - description: `Deleted permission group "${result.group.name}"`, - request: req, - }) + await db.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id)) + await db.delete(permissionGroup).where(eq(permissionGroup.id, id)) + + logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id }) + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_DELETED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.group.name, + description: `Deleted permission group "${result.group.name}"`, + request: req, + }) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting permission group', error) - return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting permission group', error) + return NextResponse.json({ error: 'Failed to delete permission group' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/permission-groups/route.ts b/apps/sim/app/api/permission-groups/route.ts index b79f01ebc5f..184fc8d2bc5 100644 --- a/apps/sim/app/api/permission-groups/route.ts +++ b/apps/sim/app/api/permission-groups/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -46,7 +47,7 @@ const createSchema = z.object({ autoAddNewMembers: z.boolean().optional(), }) -export async function GET(req: Request) { +export const GET = withRouteHandler(async (req: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -104,9 +105,9 @@ export async function GET(req: Request) { ) return NextResponse.json({ permissionGroups: groupsWithCounts }) -} +}) -export async function POST(req: Request) { +export const POST = withRouteHandler(async (req: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -222,4 +223,4 @@ export async function POST(req: Request) { logger.error('Error creating permission group', error) return NextResponse.json({ error: 'Failed to create permission group' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/permission-groups/user/route.ts b/apps/sim/app/api/permission-groups/user/route.ts index e41c8265338..d4751cd7ace 100644 --- a/apps/sim/app/api/permission-groups/user/route.ts +++ b/apps/sim/app/api/permission-groups/user/route.ts @@ -4,9 +4,10 @@ import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parsePermissionGroupConfig } from '@/lib/permission-groups/types' -export async function GET(req: Request) { +export const GET = withRouteHandler(async (req: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -69,4 +70,4 @@ export async function GET(req: Request) { groupName: groupMembership.groupName, config: parsePermissionGroupConfig(groupMembership.config), }) -} +}) diff --git a/apps/sim/app/api/providers/base/models/route.ts b/apps/sim/app/api/providers/base/models/route.ts index 6733eaf5f40..93c6da59762 100644 --- a/apps/sim/app/api/providers/base/models/route.ts +++ b/apps/sim/app/api/providers/base/models/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getBaseModelProviders } from '@/providers/utils' -export async function GET() { +export const GET = withRouteHandler(async () => { try { const allModels = Object.keys(getBaseModelProviders()) return NextResponse.json({ models: allModels }) } catch (error) { return NextResponse.json({ models: [], error: 'Failed to fetch models' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/providers/fireworks/models/route.ts b/apps/sim/app/api/providers/fireworks/models/route.ts index 070d860efcf..8bd47a78862 100644 --- a/apps/sim/app/api/providers/fireworks/models/route.ts +++ b/apps/sim/app/api/providers/fireworks/models/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getBYOKKey } from '@/lib/api-key/byok' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' @@ -20,7 +21,7 @@ interface FireworksModelsResponse { object?: string } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { if (isProviderBlacklisted('fireworks')) { logger.info('Fireworks provider is blacklisted, returning empty models') return NextResponse.json({ models: [] }) @@ -90,4 +91,4 @@ export async function GET(request: NextRequest) { }) return NextResponse.json({ models: [] }) } -} +}) diff --git a/apps/sim/app/api/providers/ollama/models/route.ts b/apps/sim/app/api/providers/ollama/models/route.ts index 44434eadca4..27b2a5c655c 100644 --- a/apps/sim/app/api/providers/ollama/models/route.ts +++ b/apps/sim/app/api/providers/ollama/models/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { ModelsObject } from '@/providers/ollama/types' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' @@ -10,7 +11,7 @@ const OLLAMA_HOST = env.OLLAMA_URL || 'http://localhost:11434' /** * Get available Ollama models */ -export async function GET(_request: NextRequest) { +export const GET = withRouteHandler(async (_request: NextRequest) => { if (isProviderBlacklisted('ollama')) { logger.info('Ollama provider is blacklisted, returning empty models') return NextResponse.json({ models: [] }) @@ -55,4 +56,4 @@ export async function GET(_request: NextRequest) { return NextResponse.json({ models: [] }) } -} +}) diff --git a/apps/sim/app/api/providers/openrouter/models/route.ts b/apps/sim/app/api/providers/openrouter/models/route.ts index 8370bae96d6..b0e3346d4a5 100644 --- a/apps/sim/app/api/providers/openrouter/models/route.ts +++ b/apps/sim/app/api/providers/openrouter/models/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' const logger = createLogger('OpenRouterModelsAPI') @@ -29,7 +30,7 @@ export interface OpenRouterModelInfo { } } -export async function GET(_request: NextRequest) { +export const GET = withRouteHandler(async (_request: NextRequest) => { if (isProviderBlacklisted('openrouter')) { logger.info('OpenRouter provider is blacklisted, returning empty models') return NextResponse.json({ models: [], modelInfo: {} }) @@ -93,4 +94,4 @@ export async function GET(_request: NextRequest) { }) return NextResponse.json({ models: [], modelInfo: {} }) } -} +}) diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index b1ad3317728..e0f2b0ac505 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { getServiceAccountToken, @@ -21,7 +22,7 @@ export const dynamic = 'force-dynamic' /** * Server-side proxy for provider requests */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() @@ -270,7 +271,7 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) /** * Helper function to sanitize tool calls to remove Unicode characters diff --git a/apps/sim/app/api/providers/vllm/models/route.ts b/apps/sim/app/api/providers/vllm/models/route.ts index 1aab4633e68..3f1dcc3a260 100644 --- a/apps/sim/app/api/providers/vllm/models/route.ts +++ b/apps/sim/app/api/providers/vllm/models/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' const logger = createLogger('VLLMModelsAPI') @@ -8,7 +9,7 @@ const logger = createLogger('VLLMModelsAPI') /** * Get available vLLM models */ -export async function GET(_request: NextRequest) { +export const GET = withRouteHandler(async (_request: NextRequest) => { if (isProviderBlacklisted('vllm')) { logger.info('vLLM provider is blacklisted, returning empty models') return NextResponse.json({ models: [] }) @@ -66,4 +67,4 @@ export async function GET(_request: NextRequest) { return NextResponse.json({ models: [] }) } -} +}) diff --git a/apps/sim/app/api/proxy/tts/stream/route.ts b/apps/sim/app/api/proxy/tts/stream/route.ts index 807c19d9005..ad6d51e7bb7 100644 --- a/apps/sim/app/api/proxy/tts/stream/route.ts +++ b/apps/sim/app/api/proxy/tts/stream/route.ts @@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server' import { env } from '@/lib/core/config/env' import { validateAuthToken } from '@/lib/core/security/deployment' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ProxyTTSStreamAPI') @@ -51,7 +52,7 @@ async function validateChatAuth(request: NextRequest, chatId: string): Promise { try { let body: any try { @@ -175,4 +176,4 @@ export async function POST(request: NextRequest) { status: 500, }) } -} +}) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 1a75d8aa598..80f29456c4e 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuthType } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { setExecutionMeta } from '@/lib/execution/event-buffer' import { preprocessExecution } from '@/lib/execution/preprocessing' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' @@ -14,207 +15,211 @@ const logger = createLogger('WorkflowResumeAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function POST( - request: NextRequest, - { - params, - }: { - params: Promise<{ workflowId: string; executionId: string; contextId: string }> - } -) { - const { workflowId, executionId, contextId } = await params +export const POST = withRouteHandler( + async ( + request: NextRequest, + { + params, + }: { + params: Promise<{ workflowId: string; executionId: string; contextId: string }> + } + ) => { + const { workflowId, executionId, contextId } = await params - // Allow resume from dashboard without requiring deployment - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } + // Allow resume from dashboard without requiring deployment + const access = await validateWorkflowAccess(request, workflowId, false) + if (access.error) { + return NextResponse.json({ error: access.error.message }, { status: access.error.status }) + } - const workflow = access.workflow + const workflow = access.workflow - let payload: Record = {} - try { - payload = await request.json() - } catch { - payload = {} - } + let payload: Record = {} + try { + payload = await request.json() + } catch { + payload = {} + } - const resumeInput = payload?.input ?? payload ?? {} - const isPersonalApiKeyCaller = - access.auth?.authType === AuthType.API_KEY && access.auth?.apiKeyType === 'personal' - - let userId: string - if (isPersonalApiKeyCaller && access.auth?.userId) { - userId = access.auth.userId - } else { - const billedAccountUserId = await getWorkspaceBilledAccountUserId(workflow.workspaceId) - if (!billedAccountUserId) { - logger.error('Unable to resolve workspace billed account for resume execution', { - workflowId, - workspaceId: workflow.workspaceId, - }) - return NextResponse.json( - { error: 'Unable to resolve billing account for this workspace' }, - { status: 500 } - ) + const resumeInput = payload?.input ?? payload ?? {} + const isPersonalApiKeyCaller = + access.auth?.authType === AuthType.API_KEY && access.auth?.apiKeyType === 'personal' + + let userId: string + if (isPersonalApiKeyCaller && access.auth?.userId) { + userId = access.auth.userId + } else { + const billedAccountUserId = await getWorkspaceBilledAccountUserId(workflow.workspaceId) + if (!billedAccountUserId) { + logger.error('Unable to resolve workspace billed account for resume execution', { + workflowId, + workspaceId: workflow.workspaceId, + }) + return NextResponse.json( + { error: 'Unable to resolve billing account for this workspace' }, + { status: 500 } + ) + } + userId = billedAccountUserId } - userId = billedAccountUserId - } - const resumeExecutionId = generateId() - const requestId = generateRequestId() - - logger.info(`[${requestId}] Preprocessing resume execution`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - userId, - }) - - const preprocessResult = await preprocessExecution({ - workflowId, - userId, - triggerType: 'manual', // Resume is a manual trigger - executionId: resumeExecutionId, - requestId, - checkRateLimit: false, // Manual triggers bypass rate limits - checkDeployment: false, // Resuming existing execution, deployment already checked - skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits - useAuthenticatedUserAsActor: isPersonalApiKeyCaller, - workspaceId: workflow.workspaceId || undefined, - }) - - if (!preprocessResult.success) { - logger.warn(`[${requestId}] Preprocessing failed for resume`, { + const resumeExecutionId = generateId() + const requestId = generateRequestId() + + logger.info(`[${requestId}] Preprocessing resume execution`, { workflowId, parentExecutionId: executionId, - error: preprocessResult.error?.message, - statusCode: preprocessResult.error?.statusCode, + resumeExecutionId, + userId, }) - return NextResponse.json( - { - error: - preprocessResult.error?.message || - 'Failed to validate resume execution. Please try again.', - }, - { status: preprocessResult.error?.statusCode || 400 } - ) - } - - logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { - workflowId, - parentExecutionId: executionId, - resumeExecutionId, - actorUserId: preprocessResult.actorUserId, - }) - - try { - const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ - executionId, - contextId, - resumeInput, + const preprocessResult = await preprocessExecution({ + workflowId, userId, + triggerType: 'manual', // Resume is a manual trigger + executionId: resumeExecutionId, + requestId, + checkRateLimit: false, // Manual triggers bypass rate limits + checkDeployment: false, // Resuming existing execution, deployment already checked + skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits + useAuthenticatedUserAsActor: isPersonalApiKeyCaller, + workspaceId: workflow.workspaceId || undefined, }) - if (enqueueResult.status === 'queued') { - return NextResponse.json({ - status: 'queued', - executionId: enqueueResult.resumeExecutionId, - queuePosition: enqueueResult.queuePosition, - message: 'Resume queued. It will run after current resumes finish.', + if (!preprocessResult.success) { + logger.warn(`[${requestId}] Preprocessing failed for resume`, { + workflowId, + parentExecutionId: executionId, + error: preprocessResult.error?.message, + statusCode: preprocessResult.error?.statusCode, }) + + return NextResponse.json( + { + error: + preprocessResult.error?.message || + 'Failed to validate resume execution. Please try again.', + }, + { status: preprocessResult.error?.statusCode || 400 } + ) } - await setExecutionMeta(enqueueResult.resumeExecutionId, { - status: 'active', - userId, + logger.info(`[${requestId}] Preprocessing passed, proceeding with resume`, { workflowId, + parentExecutionId: executionId, + resumeExecutionId, + actorUserId: preprocessResult.actorUserId, }) - const resumeArgs = { - resumeEntryId: enqueueResult.resumeEntryId, - resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecution: enqueueResult.pausedExecution, - contextId: enqueueResult.contextId, - resumeInput: enqueueResult.resumeInput, - userId: enqueueResult.userId, - } + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId, + contextId, + resumeInput, + userId, + }) - const isApiCaller = access.auth?.authType === AuthType.API_KEY + if (enqueueResult.status === 'queued') { + return NextResponse.json({ + status: 'queued', + executionId: enqueueResult.resumeExecutionId, + queuePosition: enqueueResult.queuePosition, + message: 'Resume queued. It will run after current resumes finish.', + }) + } + + await setExecutionMeta(enqueueResult.resumeExecutionId, { + status: 'active', + userId, + workflowId, + }) - if (isApiCaller) { - const result = await PauseResumeManager.startResumeExecution(resumeArgs) + const resumeArgs = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecution: enqueueResult.pausedExecution, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + } + + const isApiCaller = access.auth?.authType === AuthType.API_KEY + + if (isApiCaller) { + const result = await PauseResumeManager.startResumeExecution(resumeArgs) + + return NextResponse.json({ + success: result.success, + status: result.status ?? (result.success ? 'completed' : 'failed'), + executionId: enqueueResult.resumeExecutionId, + output: result.output, + error: result.error, + metadata: result.metadata + ? { + duration: result.metadata.duration, + startTime: result.metadata.startTime, + endTime: result.metadata.endTime, + } + : undefined, + }) + } + + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { + logger.error('Failed to start resume execution', { + workflowId, + parentExecutionId: executionId, + resumeExecutionId: enqueueResult.resumeExecutionId, + error, + }) + }) return NextResponse.json({ - success: result.success, - status: result.status ?? (result.success ? 'completed' : 'failed'), + status: 'started', executionId: enqueueResult.resumeExecutionId, - output: result.output, - error: result.error, - metadata: result.metadata - ? { - duration: result.metadata.duration, - startTime: result.metadata.startTime, - endTime: result.metadata.endTime, - } - : undefined, + message: 'Resume execution started.', }) - } - - PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { - logger.error('Failed to start resume execution', { + } catch (error: any) { + logger.error('Resume request failed', { workflowId, - parentExecutionId: executionId, - resumeExecutionId: enqueueResult.resumeExecutionId, + executionId, + contextId, error, }) - }) + return NextResponse.json( + { error: error.message || 'Failed to queue resume request' }, + { status: 400 } + ) + } + } +) + +export const GET = withRouteHandler( + async ( + request: NextRequest, + { + params, + }: { + params: Promise<{ workflowId: string; executionId: string; contextId: string }> + } + ) => { + const { workflowId, executionId, contextId } = await params - return NextResponse.json({ - status: 'started', - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution started.', - }) - } catch (error: any) { - logger.error('Resume request failed', { + // Allow access without API key for browser-based UI (same as parent execution endpoint) + const access = await validateWorkflowAccess(request, workflowId, false) + if (access.error) { + return NextResponse.json({ error: access.error.message }, { status: access.error.status }) + } + + const detail = await PauseResumeManager.getPauseContextDetail({ workflowId, executionId, contextId, - error, }) - return NextResponse.json( - { error: error.message || 'Failed to queue resume request' }, - { status: 400 } - ) - } -} - -export async function GET( - request: NextRequest, - { - params, - }: { - params: Promise<{ workflowId: string; executionId: string; contextId: string }> - } -) { - const { workflowId, executionId, contextId } = await params - - // Allow access without API key for browser-based UI (same as parent execution endpoint) - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } - const detail = await PauseResumeManager.getPauseContextDetail({ - workflowId, - executionId, - contextId, - }) + if (!detail) { + return NextResponse.json({ error: 'Pause context not found' }, { status: 404 }) + } - if (!detail) { - return NextResponse.json({ error: 'Pause context not found' }, { status: 404 }) + return NextResponse.json(detail) } - - return NextResponse.json(detail) -} +) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts index 1e3cc4b53e5..264f6d592e7 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' @@ -8,41 +9,43 @@ const logger = createLogger('WorkflowResumeExecutionAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function GET( - request: NextRequest, - { - params, - }: { - params: Promise<{ workflowId: string; executionId: string }> - } -) { - const { workflowId, executionId } = await params +export const GET = withRouteHandler( + async ( + request: NextRequest, + { + params, + }: { + params: Promise<{ workflowId: string; executionId: string }> + } + ) => { + const { workflowId, executionId } = await params - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } + const access = await validateWorkflowAccess(request, workflowId, false) + if (access.error) { + return NextResponse.json({ error: access.error.message }, { status: access.error.status }) + } - try { - const detail = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) + try { + const detail = await PauseResumeManager.getPausedExecutionDetail({ + workflowId, + executionId, + }) - if (!detail) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } + if (!detail) { + return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) + } - return NextResponse.json(detail) - } catch (error: any) { - logger.error('Failed to load paused execution detail', { - workflowId, - executionId, - error, - }) - return NextResponse.json( - { error: error?.message || 'Failed to load paused execution detail' }, - { status: 500 } - ) + return NextResponse.json(detail) + } catch (error: any) { + logger.error('Failed to load paused execution detail', { + workflowId, + executionId, + error, + }) + return NextResponse.json( + { error: error?.message || 'Failed to load paused execution detail' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 597634aeb9d..85471d0d063 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -104,42 +105,145 @@ async function fetchAndAuthorize( return { schedule, workspaceId: authorization.workflow.workspaceId ?? null } } -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - try { - const { id: scheduleId } = await params + try { + const { id: scheduleId } = await params - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized schedule update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const body = await request.json() - const validation = scheduleUpdateSchema.safeParse(body) + const body = await request.json() + const validation = scheduleUpdateSchema.safeParse(body) - if (!validation.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + if (!validation.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } - const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write') - if (result instanceof NextResponse) return result - const { schedule, workspaceId } = result + const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write') + if (result instanceof NextResponse) return result + const { schedule, workspaceId } = result - const { action } = validation.data + const { action } = validation.data - if (action === 'disable') { - if (schedule.status === 'disabled') { - return NextResponse.json({ message: 'Schedule is already disabled' }) + if (action === 'disable') { + if (schedule.status === 'disabled') { + return NextResponse.json({ message: 'Schedule is already disabled' }) + } + + await db + .update(workflowSchedule) + .set({ status: 'disabled', nextRunAt: null, updatedAt: new Date() }) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) + + logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: scheduleId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Disabled schedule ${scheduleId}`, + metadata: {}, + request, + }) + + return NextResponse.json({ message: 'Schedule disabled successfully' }) } + if (action === 'update') { + if (schedule.sourceType !== 'job') { + return NextResponse.json( + { error: 'Only standalone job schedules can be edited' }, + { status: 400 } + ) + } + + const updates = validation.data + const setFields: Record = { updatedAt: new Date() } + + if (updates.title !== undefined) setFields.jobTitle = updates.title.trim() + if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim() + if (updates.timezone !== undefined) setFields.timezone = updates.timezone + if (updates.lifecycle !== undefined) { + setFields.lifecycle = updates.lifecycle + if (updates.lifecycle === 'persistent') { + setFields.maxRuns = null + } + } + if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns + + if (updates.cronExpression !== undefined) { + const tz = updates.timezone ?? schedule.timezone ?? 'UTC' + const cronResult = validateCronExpression(updates.cronExpression, tz) + if (!cronResult.isValid) { + return NextResponse.json( + { error: cronResult.error || 'Invalid cron expression' }, + { status: 400 } + ) + } + setFields.cronExpression = updates.cronExpression + if (schedule.status === 'active' && cronResult.nextRun) { + setFields.nextRunAt = cronResult.nextRun + } + } + + await db + .update(workflowSchedule) + .set(setFields) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) + + logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: scheduleId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Updated job schedule ${scheduleId}`, + metadata: {}, + request, + }) + + return NextResponse.json({ message: 'Schedule updated successfully' }) + } + + // reactivate + if (schedule.status === 'active') { + return NextResponse.json({ message: 'Schedule is already active' }) + } + + if (!schedule.cronExpression) { + logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 }) + } + + const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC') + if (!cronResult.isValid || !cronResult.nextRun) { + logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`) + return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 }) + } + + const now = new Date() + const nextRunAt = cronResult.nextRun + await db .update(workflowSchedule) - .set({ status: 'disabled', nextRunAt: null, updatedAt: new Date() }) + .set({ status: 'active', failedCount: 0, updatedAt: now, nextRunAt }) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) + logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) recordAudit({ workspaceId, @@ -149,57 +253,39 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ resourceId: scheduleId, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - description: `Disabled schedule ${scheduleId}`, - metadata: {}, + description: `Reactivated schedule ${scheduleId}`, + metadata: { cronExpression: schedule.cronExpression, timezone: schedule.timezone }, request, }) - return NextResponse.json({ message: 'Schedule disabled successfully' }) + return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt }) + } catch (error) { + logger.error(`[${requestId}] Error updating schedule`, error) + return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) } + } +) - if (action === 'update') { - if (schedule.sourceType !== 'job') { - return NextResponse.json( - { error: 'Only standalone job schedules can be edited' }, - { status: 400 } - ) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - const updates = validation.data - const setFields: Record = { updatedAt: new Date() } + try { + const { id: scheduleId } = await params - if (updates.title !== undefined) setFields.jobTitle = updates.title.trim() - if (updates.prompt !== undefined) setFields.prompt = updates.prompt.trim() - if (updates.timezone !== undefined) setFields.timezone = updates.timezone - if (updates.lifecycle !== undefined) { - setFields.lifecycle = updates.lifecycle - if (updates.lifecycle === 'persistent') { - setFields.maxRuns = null - } + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized schedule delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (updates.maxRuns !== undefined) setFields.maxRuns = updates.maxRuns - if (updates.cronExpression !== undefined) { - const tz = updates.timezone ?? schedule.timezone ?? 'UTC' - const cronResult = validateCronExpression(updates.cronExpression, tz) - if (!cronResult.isValid) { - return NextResponse.json( - { error: cronResult.error || 'Invalid cron expression' }, - { status: 400 } - ) - } - setFields.cronExpression = updates.cronExpression - if (schedule.status === 'active' && cronResult.nextRun) { - setFields.nextRunAt = cronResult.nextRun - } - } + const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write') + if (result instanceof NextResponse) return result + const { schedule, workspaceId } = result - await db - .update(workflowSchedule) - .set(setFields) - .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) - logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) + logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`) recordAudit({ workspaceId, @@ -209,106 +295,22 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ resourceId: scheduleId, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - description: `Updated job schedule ${scheduleId}`, + description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} ${scheduleId}`, metadata: {}, request, }) - return NextResponse.json({ message: 'Schedule updated successfully' }) - } - - // reactivate - if (schedule.status === 'active') { - return NextResponse.json({ message: 'Schedule is already active' }) - } - - if (!schedule.cronExpression) { - logger.error(`[${requestId}] Schedule has no cron expression: ${scheduleId}`) - return NextResponse.json({ error: 'Schedule has no cron expression' }, { status: 400 }) - } - - const cronResult = validateCronExpression(schedule.cronExpression, schedule.timezone || 'UTC') - if (!cronResult.isValid || !cronResult.nextRun) { - logger.error(`[${requestId}] Invalid cron expression for schedule: ${scheduleId}`) - return NextResponse.json({ error: 'Schedule has invalid cron expression' }, { status: 400 }) - } - - const now = new Date() - const nextRunAt = cronResult.nextRun - - await db - .update(workflowSchedule) - .set({ status: 'active', failedCount: 0, updatedAt: now, nextRunAt }) - .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) - - logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Reactivated schedule ${scheduleId}`, - metadata: { cronExpression: schedule.cronExpression, timezone: schedule.timezone }, - request, - }) - - return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt }) - } catch (error) { - logger.error(`[${requestId}] Error updating schedule`, error) - return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 }) - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - - try { - const { id: scheduleId } = await params - - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized schedule delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + captureServerEvent( + session.user.id, + 'scheduled_task_deleted', + { workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + + return NextResponse.json({ message: 'Schedule deleted successfully' }) + } catch (error) { + logger.error(`[${requestId}] Error deleting schedule`, error) + return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 }) } - - const result = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'write') - if (result instanceof NextResponse) return result - const { schedule, workspaceId } = result - - await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId)) - - logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} ${scheduleId}`, - metadata: {}, - request, - }) - - captureServerEvent( - session.user.id, - 'scheduled_task_deleted', - { workspace_id: workspaceId ?? '' }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) - - return NextResponse.json({ message: 'Schedule deleted successfully' }) - } catch (error) { - logger.error(`[${requestId}] Error deleting schedule`, error) - return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 0d1e41a9e1f..ad4293c25bf 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -7,6 +7,7 @@ import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch' import { executeJobInline, @@ -30,7 +31,7 @@ const dueFilter = (queuedAt: Date) => ) ) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Scheduled execution triggered at ${new Date().toISOString()}`) @@ -258,4 +259,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error in scheduled execution handler`, error) return NextResponse.json({ error: error.message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index da291cdcccc..0c33c5f8537 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { validateCronExpression } from '@/lib/workflows/schedules/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -20,7 +21,7 @@ const logger = createLogger('ScheduledAPI') * - workflowId + optional blockId → single schedule for one workflow * - workspaceId → all schedules across the workspace */ -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const url = new URL(req.url) const workflowId = url.searchParams.get('workflowId') @@ -115,7 +116,7 @@ export async function GET(req: NextRequest) { logger.error(`[${requestId}] Error retrieving workflow schedule`, error) return NextResponse.json({ error: 'Failed to retrieve workflow schedule' }, { status: 500 }) } -} +}) async function handleWorkspaceSchedules(requestId: string, userId: string, workspaceId: string) { const hasPermission = await verifyWorkspaceMembership(userId, workspaceId) @@ -190,7 +191,7 @@ async function handleWorkspaceSchedules(requestId: string, userId: string, works * * Body: { workspaceId, title, prompt, cronExpression, timezone, lifecycle?, maxRuns?, startDate? } */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -294,4 +295,4 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error creating schedule`, error) return NextResponse.json({ error: 'Failed to create schedule' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts index d05887641f0..7f4a45dada4 100644 --- a/apps/sim/app/api/settings/allowed-integrations/route.ts +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -export async function GET() { +export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -11,4 +12,4 @@ export async function GET() { return NextResponse.json({ allowedIntegrations: getAllowedIntegrationsFromEnv(), }) -} +}) diff --git a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts index 07ec5d10791..207eb706165 100644 --- a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts +++ b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts @@ -2,8 +2,9 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -export async function GET() { +export const GET = withRouteHandler(async () => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -24,4 +25,4 @@ export async function GET() { } catch {} return NextResponse.json({ allowedMcpDomains: configuredDomains }) -} +}) diff --git a/apps/sim/app/api/skills/import/route.ts b/apps/sim/app/api/skills/import/route.ts index 9cbc6e32290..8ce31a22fbf 100644 --- a/apps/sim/app/api/skills/import/route.ts +++ b/apps/sim/app/api/skills/import/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SkillsImportAPI') @@ -42,7 +43,7 @@ function toRawGitHubUrl(url: string): string { } /** POST - Fetch a SKILL.md from a GitHub URL and return its raw content */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -104,4 +105,4 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error importing skill`, error) return NextResponse.json({ error: 'Failed to import skill' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 41173c13188..70c58bb2434 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -28,7 +29,7 @@ const SkillSchema = z.object({ }) /** GET - Fetch all skills for a workspace */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const workspaceId = searchParams.get('workspaceId') @@ -60,10 +61,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching skills:`, error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) } -} +}) /** POST - Create or update skills */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -137,10 +138,10 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) } -} +}) /** DELETE - Delete a skill by ID */ -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const skillId = searchParams.get('id') @@ -204,4 +205,4 @@ export async function DELETE(request: NextRequest) { logger.error(`[${requestId}] Error deleting skill:`, error) return NextResponse.json({ error: 'Failed to delete skill' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/stars/route.ts b/apps/sim/app/api/stars/route.ts index 97106b13842..1b60d089ea4 100644 --- a/apps/sim/app/api/stars/route.ts +++ b/apps/sim/app/api/stars/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' function formatStarCount(num: number): string { if (num < 1000) return String(num) @@ -7,7 +8,7 @@ function formatStarCount(num: number): string { return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k` } -export async function GET() { +export const GET = withRouteHandler(async () => { try { const token = env.GITHUB_TOKEN const response = await fetch('https://api.github.com/repos/simstudioai/sim', { @@ -32,4 +33,4 @@ export async function GET() { console.warn('Error fetching GitHub stars:', error) return NextResponse.json({ stars: formatStarCount(19400) }) } -} +}) diff --git a/apps/sim/app/api/status/route.ts b/apps/sim/app/api/status/route.ts index 8c7a28a1745..b4e37e13071 100644 --- a/apps/sim/app/api/status/route.ts +++ b/apps/sim/app/api/status/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { IncidentIOWidgetResponse, StatusResponse, StatusType } from '@/app/api/status/types' const logger = createLogger('StatusAPI') @@ -30,7 +31,7 @@ function determineStatus(data: IncidentIOWidgetResponse): { return { status: 'operational', message: 'All Systems Operational' } } -export async function GET() { +export const GET = withRouteHandler(async () => { try { const now = Date.now() @@ -94,4 +95,4 @@ export async function GET() { }, }) } -} +}) diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 960723d31ed..0f9d45d182c 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { @@ -31,7 +32,7 @@ interface ImportWorkflowRequest { * * Requires both isSuperUser flag AND superUserModeEnabled setting. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -197,4 +198,4 @@ export async function POST(request: NextRequest) { logger.error('Error importing workflow', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/table/[tableId]/columns/route.ts b/apps/sim/app/api/table/[tableId]/columns/route.ts index de69649bf0a..9b6864c33d4 100644 --- a/apps/sim/app/api/table/[tableId]/columns/route.ts +++ b/apps/sim/app/api/table/[tableId]/columns/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { addTableColumn, deleteColumn, @@ -26,206 +27,212 @@ interface ColumnsRouteParams { } /** POST /api/table/[tableId]/columns - Adds a column to the table schema. */ -export async function POST(request: NextRequest, { params }: ColumnsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column creation attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const POST = withRouteHandler( + async (request: NextRequest, { params }: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized column creation attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const body = await request.json() - const validated = CreateColumnSchema.parse(body) + const body = await request.json() + const validated = CreateColumnSchema.parse(body) - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result + const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const updatedTable = await addTableColumn(tableId, validated.column, requestId) - - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } + const updatedTable = await addTableColumn(tableId, validated.column, requestId) - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) + + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error.message === 'Table not found') { + return NextResponse.json({ error: error.message }, { status: 404 }) + } } - } - logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) + logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) + } } -} +) /** PATCH /api/table/[tableId]/columns - Updates a column (rename, type change, constraints). */ -export async function PATCH(request: NextRequest, { params }: ColumnsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column update attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized column update attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const body = await request.json() - const validated = UpdateColumnSchema.parse(body) + const body = await request.json() + const validated = UpdateColumnSchema.parse(body) - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result + const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const { updates } = validated - let updatedTable = null + const { updates } = validated + let updatedTable = null - if (updates.name) { - updatedTable = await renameColumn( - { tableId, oldName: validated.columnName, newName: updates.name }, - requestId - ) - } + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + requestId + ) + } - if (updates.type) { - updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, - requestId - ) - } + if (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } - if (updates.required !== undefined || updates.unique !== undefined) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: updates.name ?? validated.columnName, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - }, - requestId - ) - } + if (updates.required !== undefined || updates.unique !== undefined) { + updatedTable = await updateColumnConstraints( + { + tableId, + columnName: updates.name ?? validated.columnName, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + }, + requestId + ) + } - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + if (!updatedTable) { + return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) + if (error instanceof Error) { + const msg = error.message + if (msg.includes('not found') || msg.includes('Table not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } } - } - logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) + logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) + } } -} +) /** DELETE /api/table/[tableId]/columns - Deletes a column from the table schema. */ -export async function DELETE(request: NextRequest, { params }: ColumnsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized column deletion attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized column deletion attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const body = await request.json() - const validated = DeleteColumnSchema.parse(body) + const body = await request.json() + const validated = DeleteColumnSchema.parse(body) - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result + const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const updatedTable = await deleteColumn( - { tableId, columnName: validated.columnName }, - requestId - ) - - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } + const updatedTable = await deleteColumn( + { tableId, columnName: validated.columnName }, + requestId ) - } - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) + + if (error instanceof Error) { + if (error.message.includes('not found') || error.message === 'Table not found') { + return NextResponse.json({ error: error.message }, { status: 404 }) + } + if (error.message.includes('Cannot delete') || error.message.includes('last column')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } } - } - logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) + logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index 29bed2f3823..4634bf428ed 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableMetadata } from '@/lib/table' import { updateTableMetadata } from '@/lib/table' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -22,7 +23,7 @@ interface TableRouteParams { } /** PUT /api/table/[tableId]/metadata - Update table UI metadata (column widths, etc.) */ -export async function PUT(request: NextRequest, { params }: TableRouteParams) { +export const PUT = withRouteHandler(async (request: NextRequest, { params }: TableRouteParams) => { const requestId = generateRequestId() const { tableId } = await params @@ -63,4 +64,4 @@ export async function PUT(request: NextRequest, { params }: TableRouteParams) { logger.error(`[${requestId}] Error updating table metadata:`, error) return NextResponse.json({ error: 'Failed to update metadata' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 9175de0b661..df6c16fb5c8 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -3,61 +3,61 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getTableById, restoreTable, TableConflictError } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreTableAPI') -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ tableId: string }> } -) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ tableId: string }> }) => { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const table = await getTableById(tableId, { includeArchived: true }) + if (!table) { + return NextResponse.json({ error: 'Table not found' }, { status: 404 }) + } + + const permission = await getUserEntityPermissions(auth.userId, 'workspace', table.workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + await restoreTable(tableId, requestId) + + logger.info(`[${requestId}] Restored table ${tableId}`) + + recordAudit({ + workspaceId: table.workspaceId, + actorId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.TABLE_RESTORED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Restored table "${table.name}"`, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof TableConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + + logger.error(`[${requestId}] Error restoring table ${tableId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) } - - const table = await getTableById(tableId, { includeArchived: true }) - if (!table) { - return NextResponse.json({ error: 'Table not found' }, { status: 404 }) - } - - const permission = await getUserEntityPermissions(auth.userId, 'workspace', table.workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - await restoreTable(tableId, requestId) - - logger.info(`[${requestId}] Restored table ${tableId}`) - - recordAudit({ - workspaceId: table.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.TABLE_RESTORED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Restored table "${table.name}"`, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - - logger.error(`[${requestId}] Error restoring table ${tableId}`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 1e84313c028..bdcb42a8a92 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { deleteTable, @@ -25,7 +26,7 @@ interface TableRouteParams { } /** GET /api/table/[tableId] - Retrieves a single table's details. */ -export async function GET(request: NextRequest, { params }: TableRouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: TableRouteParams) => { const requestId = generateRequestId() const { tableId } = await params @@ -89,7 +90,7 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) { logger.error(`[${requestId}] Error getting table:`, error) return NextResponse.json({ error: 'Failed to get table' }, { status: 500 }) } -} +}) const PatchTableSchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), @@ -107,105 +108,109 @@ const PatchTableSchema = z.object({ }) /** PATCH /api/table/[tableId] - Renames a table. */ -export async function PATCH(request: NextRequest, { params }: TableRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized table rename attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const body = await request.json() - const validated = PatchTableSchema.parse(body) - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - - const { table } = result - - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const updated = await renameTable(tableId, validated.name, requestId) - - return NextResponse.json({ - success: true, - data: { table: updated }, - }) - } catch (error) { - if (error instanceof z.ZodError) { +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: TableRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized table rename attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const body = await request.json() + const validated = PatchTableSchema.parse(body) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const updated = await renameTable(tableId, validated.name, requestId) + + return NextResponse.json({ + success: true, + data: { table: updated }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + if (error instanceof TableConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + + logger.error(`[${requestId}] Error renaming table:`, error) return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + { error: error instanceof Error ? error.message : 'Failed to rename table' }, + { status: 500 } ) } - - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - - logger.error(`[${requestId}] Error renaming table:`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to rename table' }, - { status: 500 } - ) } -} +) /** DELETE /api/table/[tableId] - Archives a table. */ -export async function DELETE(request: NextRequest, { params }: TableRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized table delete attempt`) - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const validated = GetTableSchema.parse({ - workspaceId: searchParams.get('workspaceId'), - }) - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - - const { table } = result - - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - await deleteTable(tableId, requestId) - - captureServerEvent( - authResult.userId, - 'table_deleted', - { table_id: tableId, workspace_id: table.workspaceId }, - { groups: { workspace: table.workspaceId } } - ) - - return NextResponse.json({ - success: true, - data: { - message: 'Table archived successfully', - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: TableRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized table delete attempt`) + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const validated = GetTableSchema.parse({ + workspaceId: searchParams.get('workspaceId'), + }) + + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + await deleteTable(tableId, requestId) + + captureServerEvent( + authResult.userId, + 'table_deleted', + { table_id: tableId, workspace_id: table.workspaceId }, + { groups: { workspace: table.workspaceId } } ) - } - logger.error(`[${requestId}] Error deleting table:`, error) - return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: { + message: 'Table archived successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error deleting table:`, error) + return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 12326141c7d..6a69a47d55a 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' import { deleteRow, updateRow } from '@/lib/table' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -30,7 +31,7 @@ interface RowRouteParams { } /** GET /api/table/[tableId]/rows/[rowId] - Retrieves a single row. */ -export async function GET(request: NextRequest, { params }: RowRouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { const requestId = generateRequestId() const { tableId, rowId } = await params @@ -103,10 +104,10 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) { logger.error(`[${requestId}] Error getting row:`, error) return NextResponse.json({ error: 'Failed to get row' }, { status: 500 }) } -} +}) /** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */ -export async function PATCH(request: NextRequest, { params }: RowRouteParams) { +export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { const requestId = generateRequestId() const { tableId, rowId } = await params @@ -212,10 +213,10 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) { logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) } -} +}) /** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */ -export async function DELETE(request: NextRequest, { params }: RowRouteParams) { +export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { const requestId = generateRequestId() const { tableId, rowId } = await params @@ -269,4 +270,4 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) { logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts index 305a55a8855..72f1e4bd492 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' import { batchInsertRows, @@ -200,375 +201,409 @@ async function handleBatchInsert( } /** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */ -export async function POST(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params +export const POST = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - if ( - typeof body === 'object' && - body !== null && - 'rows' in body && - Array.isArray((body as Record).rows) - ) { - return handleBatchInsert( - requestId, - tableId, - body as z.infer, - authResult.userId - ) - } + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const validated = InsertRowSchema.parse(body) + if ( + typeof body === 'object' && + body !== null && + 'rows' in body && + Array.isArray((body as Record).rows) + ) { + return handleBatchInsert( + requestId, + tableId, + body as z.infer, + authResult.userId + ) + } - const accessResult = await checkAccess(tableId, authResult.userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const validated = InsertRowSchema.parse(body) - const { table } = accessResult + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - if (validated.workspaceId !== table.workspaceId) { - logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` - ) - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = accessResult - const rowData = validated.data as RowData + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - // Validate at route level for structured HTTP error responses - const validation = await validateRowData({ - rowData, - schema: table.schema as TableSchema, - tableId, - }) - if (!validation.valid) return validation.response + const rowData = validated.data as RowData - // Service handles atomic capacity check + insert in a transaction - const row = await insertRow( - { + // Validate at route level for structured HTTP error responses + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, tableId, - data: rowData, - workspaceId: validated.workspaceId, - userId: authResult.userId, - position: validated.position, - }, - table, - requestId - ) + }) + if (!validation.valid) return validation.response - return NextResponse.json({ - success: true, - data: { - row: { - id: row.id, - data: row.data, - position: row.position, - createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, - updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, + // Service handles atomic capacity check + insert in a transaction + const row = await insertRow( + { + tableId, + data: rowData, + workspaceId: validated.workspaceId, + userId: authResult.userId, + position: validated.position, }, - message: 'Row inserted successfully', - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + table, + requestId ) - } - const errorMessage = error instanceof Error ? error.message : String(error) - - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + return NextResponse.json({ + success: true, + data: { + row: { + id: row.id, + data: row.data, + position: row.position, + createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, + updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, + }, + message: 'Row inserted successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - logger.error(`[${requestId}] Error inserting row:`, error) - return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) - } -} + const errorMessage = error instanceof Error ? error.message : String(error) -/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */ -export async function GET(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params + if ( + errorMessage.includes('row limit') || + errorMessage.includes('Insufficient capacity') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Row size exceeds') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + logger.error(`[${requestId}] Error inserting row:`, error) + return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) } + } +) - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') - const filterParam = searchParams.get('filter') - const sortParam = searchParams.get('sort') - const limit = searchParams.get('limit') - const offset = searchParams.get('offset') - - let filter: Record | undefined - let sort: Sort | undefined +/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */ +export const GET = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params try { - if (filterParam) { - filter = JSON.parse(filterParam) as Record + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - if (sortParam) { - sort = JSON.parse(sortParam) as Sort + + const { searchParams } = new URL(request.url) + const workspaceId = searchParams.get('workspaceId') + const filterParam = searchParams.get('filter') + const sortParam = searchParams.get('sort') + const limit = searchParams.get('limit') + const offset = searchParams.get('offset') + + let filter: Record | undefined + let sort: Sort | undefined + + try { + if (filterParam) { + filter = JSON.parse(filterParam) as Record + } + if (sortParam) { + sort = JSON.parse(sortParam) as Sort + } + } catch { + return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) } - } catch { - return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) - } - const validated = QueryRowsSchema.parse({ - workspaceId, - filter, - sort, - limit, - offset, - }) + const validated = QueryRowsSchema.parse({ + workspaceId, + filter, + sort, + limit, + offset, + }) - const accessResult = await checkAccess(tableId, authResult.userId, 'read') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const accessResult = await checkAccess(tableId, authResult.userId, 'read') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` - ) - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const baseConditions = [ - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId), - ] + const baseConditions = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ] - if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) - if (filterClause) { - baseConditions.push(filterClause) + if (validated.filter) { + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + if (filterClause) { + baseConditions.push(filterClause) + } } - } - let query = db - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where(and(...baseConditions)) - - if (validated.sort) { - const schema = table.schema as TableSchema - const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) - if (sortClause) { - query = query.orderBy(sortClause) as typeof query + let query = db + .select({ + id: userTableRows.id, + data: userTableRows.data, + position: userTableRows.position, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where(and(...baseConditions)) + + if (validated.sort) { + const schema = table.schema as TableSchema + const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) + if (sortClause) { + query = query.orderBy(sortClause) as typeof query + } else { + query = query.orderBy(userTableRows.position) as typeof query + } } else { query = query.orderBy(userTableRows.position) as typeof query } - } else { - query = query.orderBy(userTableRows.position) as typeof query - } - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) + const countQuery = db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) - const [{ count: totalCount }] = await countQuery + const [{ count: totalCount }] = await countQuery - const rows = await query.limit(validated.limit).offset(validated.offset) + const rows = await query.limit(validated.limit).offset(validated.offset) - logger.info( - `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})` - ) - - return NextResponse.json({ - success: true, - data: { - rows: rows.map((r) => ({ - id: r.id, - data: r.data, - position: r.position, - createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: rows.length, - totalCount: Number(totalCount), - limit: validated.limit, - offset: validated.offset, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + logger.info( + `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})` ) - } - logger.error(`[${requestId}] Error querying rows:`, error) - return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: { + rows: rows.map((r) => ({ + id: r.id, + data: r.data, + position: r.position, + createdAt: + r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), + updatedAt: + r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), + })), + rowCount: rows.length, + totalCount: Number(totalCount), + limit: validated.limit, + offset: validated.offset, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error querying rows:`, error) + return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + } } -} +) /** PUT /api/table/[tableId]/rows - Updates rows matching filter criteria. */ -export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const validated = UpdateRowsByFilterSchema.parse(body) + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const accessResult = await checkAccess(tableId, authResult.userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const validated = UpdateRowsByFilterSchema.parse(body) - const { table } = accessResult + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - if (validated.workspaceId !== table.workspaceId) { - logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` - ) - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = accessResult - const sizeValidation = validateRowSize(validated.data as RowData) - if (!sizeValidation.valid) { - return NextResponse.json( - { error: 'Invalid row data', details: sizeValidation.errors }, - { status: 400 } - ) - } + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const result = await updateRowsByFilter( - { - tableId, - filter: validated.filter as Filter, - data: validated.data as RowData, - limit: validated.limit, - workspaceId: validated.workspaceId, - }, - table, - requestId - ) + const sizeValidation = validateRowSize(validated.data as RowData) + if (!sizeValidation.valid) { + return NextResponse.json( + { error: 'Invalid row data', details: sizeValidation.errors }, + { status: 400 } + ) + } - if (result.affectedCount === 0) { - return NextResponse.json( + const result = await updateRowsByFilter( { - success: true, - data: { - message: 'No rows matched the filter criteria', - updatedCount: 0, - }, + tableId, + filter: validated.filter as Filter, + data: validated.data as RowData, + limit: validated.limit, + workspaceId: validated.workspaceId, }, - { status: 200 } + table, + requestId ) - } - return NextResponse.json({ - success: true, - data: { - message: 'Rows updated successfully', - updatedCount: result.affectedCount, - updatedRowIds: result.affectedRowIds, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + if (result.affectedCount === 0) { + return NextResponse.json( + { + success: true, + data: { + message: 'No rows matched the filter criteria', + updatedCount: 0, + }, + }, + { status: 200 } + ) + } - const errorMessage = error instanceof Error ? error.message : String(error) + return NextResponse.json({ + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const errorMessage = error instanceof Error ? error.message : String(error) + + if ( + errorMessage.includes('Row size exceeds') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('Cannot set unique column') || + errorMessage.includes('Filter is required') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - logger.error(`[${requestId}] Error updating rows by filter:`, error) - return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) + logger.error(`[${requestId}] Error updating rows by filter:`, error) + return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) + } } -} +) /** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria or by IDs. */ -export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const validated = DeleteRowsRequestSchema.parse(body) + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const accessResult = await checkAccess(tableId, authResult.userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const validated = DeleteRowsRequestSchema.parse(body) - const { table } = accessResult + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - if (validated.workspaceId !== table.workspaceId) { - logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` - ) - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = accessResult + + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + if ('rowIds' in validated) { + const result = await deleteRowsByIds( + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + message: + result.deletedCount === 0 + ? 'No matching rows found for the provided IDs' + : 'Rows deleted successfully', + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), + }, + }) + } - if ('rowIds' in validated) { - const result = await deleteRowsByIds( - { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + const result = await deleteRowsByFilter( + { + tableId, + filter: validated.filter as Filter, + limit: validated.limit, + workspaceId: validated.workspaceId, + }, requestId ) @@ -576,133 +611,111 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar success: true, data: { message: - result.deletedCount === 0 - ? 'No matching rows found for the provided IDs' + result.affectedCount === 0 + ? 'No rows matched the filter criteria' : 'Rows deleted successfully', - deletedCount: result.deletedCount, - deletedRowIds: result.deletedRowIds, - requestedCount: result.requestedCount, - ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), + deletedCount: result.affectedCount, + deletedRowIds: result.affectedRowIds, }, }) - } + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - const result = await deleteRowsByFilter( - { - tableId, - filter: validated.filter as Filter, - limit: validated.limit, - workspaceId: validated.workspaceId, - }, - requestId - ) + const errorMessage = error instanceof Error ? error.message : String(error) - return NextResponse.json({ - success: true, - data: { - message: - result.affectedCount === 0 - ? 'No rows matched the filter criteria' - : 'Rows deleted successfully', - deletedCount: result.affectedCount, - deletedRowIds: result.affectedRowIds, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } - - const errorMessage = error instanceof Error ? error.message : String(error) + if (errorMessage.includes('Filter is required')) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) + logger.error(`[${requestId}] Error deleting rows:`, error) + return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) } - - logger.error(`[${requestId}] Error deleting rows:`, error) - return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) } -} +) /** PATCH /api/table/[tableId]/rows - Batch updates rows by ID. */ -export async function PATCH(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const validated = BatchUpdateByIdsSchema.parse(body) + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const accessResult = await checkAccess(tableId, authResult.userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const validated = BatchUpdateByIdsSchema.parse(body) - const { table } = accessResult + const accessResult = await checkAccess(tableId, authResult.userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - if (validated.workspaceId !== table.workspaceId) { - logger.warn( - `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` - ) - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = accessResult - const result = await batchUpdateRows( - { - tableId, - updates: validated.updates as Array<{ rowId: string; data: RowData }>, - workspaceId: validated.workspaceId, - }, - table, - requestId - ) + if (validated.workspaceId !== table.workspaceId) { + logger.warn( + `[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}` + ) + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: { - message: 'Rows updated successfully', - updatedCount: result.affectedCount, - updatedRowIds: result.affectedRowIds, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + const result = await batchUpdateRows( + { + tableId, + updates: validated.updates as Array<{ rowId: string; data: RowData }>, + workspaceId: validated.workspaceId, + }, + table, + requestId ) - } - const errorMessage = error instanceof Error ? error.message : String(error) + return NextResponse.json({ + success: true, + data: { + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be valid') || - errorMessage.includes('must be string') || - errorMessage.includes('must be number') || - errorMessage.includes('must be boolean') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Rows not found') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const errorMessage = error instanceof Error ? error.message : String(error) + + if ( + errorMessage.includes('Row size exceeds') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be valid') || + errorMessage.includes('must be string') || + errorMessage.includes('must be number') || + errorMessage.includes('must be boolean') || + errorMessage.includes('must be unique') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('Cannot set unique column') || + errorMessage.includes('Rows not found') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - logger.error(`[${requestId}] Error batch updating rows:`, error) - return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) + logger.error(`[${requestId}] Error batch updating rows:`, error) + return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts index f78c90b2e0c..3900e05bf0c 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' import { upsertRow } from '@/lib/table' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -20,89 +21,91 @@ interface UpsertRouteParams { } /** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */ -export async function POST(request: NextRequest, { params }: UpsertRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params +export const POST = withRouteHandler( + async (request: NextRequest, { params }: UpsertRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const validated = UpsertRowSchema.parse(body) + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const validated = UpsertRowSchema.parse(body) - const { table } = result + const result = await checkAccess(tableId, authResult.userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = result - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: validated.data as RowData, - userId: authResult.userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - return NextResponse.json({ - success: true, - data: { - row: { - id: upsertResult.row.id, - data: upsertResult.row.data, - createdAt: - upsertResult.row.createdAt instanceof Date - ? upsertResult.row.createdAt.toISOString() - : upsertResult.row.createdAt, - updatedAt: - upsertResult.row.updatedAt instanceof Date - ? upsertResult.row.updatedAt.toISOString() - : upsertResult.row.updatedAt, + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: validated.data as RowData, + userId: authResult.userId, + conflictTarget: validated.conflictTarget, }, - operation: upsertResult.operation, - message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + table, + requestId ) - } - const errorMessage = error instanceof Error ? error.message : String(error) + return NextResponse.json({ + success: true, + data: { + row: { + id: upsertResult.row.id, + data: upsertResult.row.data, + createdAt: + upsertResult.row.createdAt instanceof Date + ? upsertResult.row.createdAt.toISOString() + : upsertResult.row.createdAt, + updatedAt: + upsertResult.row.updatedAt instanceof Date + ? upsertResult.row.updatedAt.toISOString() + : upsertResult.row.updatedAt, + }, + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - // Service layer throws descriptive errors for validation/capacity issues - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const errorMessage = error instanceof Error ? error.message : String(error) + + // Service layer throws descriptive errors for validation/capacity issues + if ( + errorMessage.includes('unique column') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('conflictTarget') || + errorMessage.includes('row limit') || + errorMessage.includes('Schema validation') || + errorMessage.includes('Upsert requires') || + errorMessage.includes('Row size exceeds') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - logger.error(`[${requestId}] Error upserting row:`, error) - return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) + logger.error(`[${requestId}] Error upserting row:`, error) + return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts index f10074180c2..a69fa9afae6 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { batchInsertRows, createTable, @@ -156,7 +157,7 @@ function coerceRows( }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -274,4 +275,4 @@ export async function POST(request: NextRequest) { { status: isClientError ? 400 : 500 } ) } -} +}) diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index bac9965766f..c18a0ca87e3 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { createTable, @@ -95,7 +96,7 @@ async function checkWorkspaceAccess( } /** POST /api/table - Creates a new user-defined table. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -204,10 +205,10 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) } -} +}) /** GET /api/table - Lists all tables in a workspace. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -275,4 +276,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error listing tables:`, error) return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/telemetry/route.ts b/apps/sim/app/api/telemetry/route.ts index 1eae8acdc9f..5bac3854d9c 100644 --- a/apps/sim/app/api/telemetry/route.ts +++ b/apps/sim/app/api/telemetry/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' import { isProd } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TelemetryAPI') @@ -176,7 +177,7 @@ async function forwardToCollector(data: any): Promise { /** * Endpoint that receives telemetry events and forwards them to OpenTelemetry collector */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { let eventData try { @@ -202,4 +203,4 @@ export async function POST(req: NextRequest) { logger.error('Error processing telemetry event', error) return NextResponse.json({ error: 'Failed to process telemetry event' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/templates/[id]/og-image/route.ts b/apps/sim/app/api/templates/[id]/og-image/route.ts index f6b2dd94bfc..17cddb2f000 100644 --- a/apps/sim/app/api/templates/[id]/og-image/route.ts +++ b/apps/sim/app/api/templates/[id]/og-image/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { verifyTemplateOwnership } from '@/lib/templates/permissions' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isValidPng } from '@/lib/uploads/utils/validation' @@ -17,126 +18,130 @@ const logger = createLogger('TemplateOGImageAPI') * Upload a pre-generated OG image for a template. * Accepts base64-encoded image data in the request body. */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { authorized, error, status } = await verifyTemplateOwnership( - id, - session.user.id, - 'admin' - ) - if (!authorized) { - logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`) - return NextResponse.json({ error }, { status: status || 403 }) - } - - const body = await request.json() - const { imageData } = body - - if (!imageData || typeof imageData !== 'string') { - return NextResponse.json( - { error: 'Missing or invalid imageData (expected base64 string)' }, - { status: 400 } +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized OG image upload attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { authorized, error, status } = await verifyTemplateOwnership( + id, + session.user.id, + 'admin' ) - } - - const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData - const imageBuffer = Buffer.from(base64Data, 'base64') - - if (!isValidPng(imageBuffer)) { - return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 }) - } - - const maxSize = 5 * 1024 * 1024 - if (imageBuffer.length > maxSize) { - return NextResponse.json({ error: 'Image too large. Maximum size is 5MB.' }, { status: 400 }) - } - - const timestamp = Date.now() - const storageKey = `og-images/templates/${id}/${timestamp}.png` + if (!authorized) { + logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`) + return NextResponse.json({ error }, { status: status || 403 }) + } + + const body = await request.json() + const { imageData } = body + + if (!imageData || typeof imageData !== 'string') { + return NextResponse.json( + { error: 'Missing or invalid imageData (expected base64 string)' }, + { status: 400 } + ) + } + + const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData + const imageBuffer = Buffer.from(base64Data, 'base64') + + if (!isValidPng(imageBuffer)) { + return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 }) + } + + const maxSize = 5 * 1024 * 1024 + if (imageBuffer.length > maxSize) { + return NextResponse.json( + { error: 'Image too large. Maximum size is 5MB.' }, + { status: 400 } + ) + } + + const timestamp = Date.now() + const storageKey = `og-images/templates/${id}/${timestamp}.png` + + logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`) + + const uploadResult = await uploadFile({ + file: imageBuffer, + fileName: storageKey, + contentType: 'image/png', + context: 'og-images', + preserveKey: true, + customKey: storageKey, + }) - logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`) + const baseUrl = getBaseUrl() + const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images` - const uploadResult = await uploadFile({ - file: imageBuffer, - fileName: storageKey, - contentType: 'image/png', - context: 'og-images', - preserveKey: true, - customKey: storageKey, - }) + await db + .update(templates) + .set({ + ogImageUrl, + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) - const baseUrl = getBaseUrl() - const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images` + logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`) - await db - .update(templates) - .set({ + return NextResponse.json({ + success: true, ogImageUrl, - updatedAt: new Date(), }) - .where(eq(templates.id, id)) - - logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`) - - return NextResponse.json({ - success: true, - ogImageUrl, - }) - } catch (error: unknown) { - logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error) - return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 }) + } catch (error: unknown) { + logger.error(`[${requestId}] Error uploading OG image for template ${id}:`, error) + return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 }) + } } -} +) /** * DELETE /api/templates/[id]/og-image * Remove the OG image for a template. */ -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { authorized, error, status } = await verifyTemplateOwnership( - id, - session.user.id, - 'admin' - ) - if (!authorized) { - logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`) - return NextResponse.json({ error }, { status: status || 403 }) +export const DELETE = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { authorized, error, status } = await verifyTemplateOwnership( + id, + session.user.id, + 'admin' + ) + if (!authorized) { + logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`) + return NextResponse.json({ error }, { status: status || 403 }) + } + + await db + .update(templates) + .set({ + ogImageUrl: null, + updatedAt: new Date(), + }) + .where(eq(templates.id, id)) + + logger.info(`[${requestId}] Removed OG image for template ${id}`) + + return NextResponse.json({ success: true }) + } catch (error: unknown) { + logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error) + return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 }) } - - await db - .update(templates) - .set({ - ogImageUrl: null, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - - logger.info(`[${requestId}] Removed OG image for template ${id}`) - - return NextResponse.json({ success: true }) - } catch (error: unknown) { - logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error) - return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 260b64f582b..e529e21546f 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canAccessTemplate } from '@/lib/templates/permissions' import { extractRequiredCredentials, @@ -18,77 +19,82 @@ const logger = createLogger('TemplateByIdAPI') export const revalidate = 0 -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - const session = await getSession() + try { + const session = await getSession() - const access = await canAccessTemplate(id, session?.user?.id) - if (!access.allowed || !access.template) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const result = await db - .select({ - template: templates, - creator: templateCreators, - }) - .from(templates) - .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id)) - .where(eq(templates.id, id)) - .limit(1) - - const { template, creator } = result[0] - const templateWithCreator = { - ...template, - creator: creator || undefined, - } + const access = await canAccessTemplate(id, session?.user?.id) + if (!access.allowed || !access.template) { + logger.warn(`[${requestId}] Template not found: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } - let isStarred = false - if (session?.user?.id) { - const { templateStars } = await import('@sim/db/schema') - const starResult = await db - .select() - .from(templateStars) - .where( - sql`${templateStars.templateId} = ${id} AND ${templateStars.userId} = ${session.user.id}` - ) + const result = await db + .select({ + template: templates, + creator: templateCreators, + }) + .from(templates) + .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id)) + .where(eq(templates.id, id)) .limit(1) - isStarred = starResult.length > 0 - } - const shouldIncrementView = template.status === 'approved' - - if (shouldIncrementView) { - try { - await db - .update(templates) - .set({ - views: sql`${templates.views} + 1`, - }) - .where(eq(templates.id, id)) - } catch (viewError) { - logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError) + const { template, creator } = result[0] + const templateWithCreator = { + ...template, + creator: creator || undefined, + } + + let isStarred = false + if (session?.user?.id) { + const { templateStars } = await import('@sim/db/schema') + const starResult = await db + .select() + .from(templateStars) + .where( + sql`${templateStars.templateId} = ${id} AND ${templateStars.userId} = ${session.user.id}` + ) + .limit(1) + isStarred = starResult.length > 0 + } + + const shouldIncrementView = template.status === 'approved' + + if (shouldIncrementView) { + try { + await db + .update(templates) + .set({ + views: sql`${templates.views} + 1`, + }) + .where(eq(templates.id, id)) + } catch (viewError) { + logger.warn( + `[${requestId}] Failed to increment view count for template: ${id}`, + viewError + ) + } } - } - logger.info(`[${requestId}] Successfully retrieved template: ${id}`) + logger.info(`[${requestId}] Successfully retrieved template: ${id}`) - return NextResponse.json({ - data: { - ...templateWithCreator, - views: template.views + (shouldIncrementView ? 1 : 0), - isStarred, - }, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + data: { + ...templateWithCreator, + views: template.views + (shouldIncrementView ? 1 : 0), + isStarred, + }, + }) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) const updateTemplateSchema = z.object({ name: z.string().min(1).max(100).optional(), @@ -105,224 +111,234 @@ const updateTemplateSchema = z.object({ }) // PUT /api/templates/[id] - Update a template -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const validationResult = updateTemplateSchema.safeParse(body) - - if (!validationResult.success) { - logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error) - return NextResponse.json( - { error: 'Invalid template data', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const { name, details, creatorId, tags, updateState, status } = validationResult.data - - const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - - if (existingTemplate.length === 0) { - logger.warn(`[${requestId}] Template not found for update: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const template = existingTemplate[0] + const body = await request.json() + const validationResult = updateTemplateSchema.safeParse(body) - // Status changes require super user permission - if (status !== undefined) { - const { verifyEffectiveSuperUser } = await import('@/lib/templates/permissions') - const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) - if (!effectiveSuperUser) { - logger.warn(`[${requestId}] Non-super user attempted to change template status: ${id}`) + if (!validationResult.success) { + logger.warn( + `[${requestId}] Invalid template data for update: ${id}`, + validationResult.error + ) return NextResponse.json( - { error: 'Only super users can change template status' }, - { status: 403 } + { error: 'Invalid template data', details: validationResult.error.errors }, + { status: 400 } ) } - } - - // For non-status updates, verify creator permission - const hasNonStatusUpdates = - name !== undefined || - details !== undefined || - creatorId !== undefined || - tags !== undefined || - updateState - if (hasNonStatusUpdates) { - if (!template.creatorId) { - logger.warn(`[${requestId}] Template ${id} has no creator, denying update`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const { name, details, creatorId, tags, updateState, status } = validationResult.data - const { verifyCreatorPermission } = await import('@/lib/templates/permissions') - const { hasPermission, error: permissionError } = await verifyCreatorPermission( - session.user.id, - template.creatorId, - 'admin' - ) + const existingTemplate = await db + .select() + .from(templates) + .where(eq(templates.id, id)) + .limit(1) - if (!hasPermission) { - logger.warn(`[${requestId}] User denied permission to update template ${id}`) - return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 }) + if (existingTemplate.length === 0) { + logger.warn(`[${requestId}] Template not found for update: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) } - } - - const updateData: any = { - updatedAt: new Date(), - } - if (name !== undefined) updateData.name = name - if (details !== undefined) updateData.details = details - if (tags !== undefined) updateData.tags = tags - if (creatorId !== undefined) updateData.creatorId = creatorId - if (status !== undefined) updateData.status = status - - if (updateState && template.workflowId) { - const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') - const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess( - session.user.id, - template.workflowId - ) - - if (!hasWorkflowAccess) { - logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`) - return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 }) + const template = existingTemplate[0] + + // Status changes require super user permission + if (status !== undefined) { + const { verifyEffectiveSuperUser } = await import('@/lib/templates/permissions') + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { + logger.warn(`[${requestId}] Non-super user attempted to change template status: ${id}`) + return NextResponse.json( + { error: 'Only super users can change template status' }, + { status: 403 } + ) + } } - const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils') - const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId) + // For non-status updates, verify creator permission + const hasNonStatusUpdates = + name !== undefined || + details !== undefined || + creatorId !== undefined || + tags !== undefined || + updateState + + if (hasNonStatusUpdates) { + if (!template.creatorId) { + logger.warn(`[${requestId}] Template ${id} has no creator, denying update`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - if (normalizedData) { - const [workflowRecord] = await db - .select({ variables: workflow.variables }) - .from(workflow) - .where(eq(workflow.id, template.workflowId)) - .limit(1) + const { verifyCreatorPermission } = await import('@/lib/templates/permissions') + const { hasPermission, error: permissionError } = await verifyCreatorPermission( + session.user.id, + template.creatorId, + 'admin' + ) - const currentState: Partial = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined, - lastSaved: Date.now(), + if (!hasPermission) { + logger.warn(`[${requestId}] User denied permission to update template ${id}`) + return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 }) } + } - const requiredCredentials = extractRequiredCredentials(currentState) + const updateData: any = { + updatedAt: new Date(), + } - const sanitizedState = sanitizeCredentials(currentState) + if (name !== undefined) updateData.name = name + if (details !== undefined) updateData.details = details + if (tags !== undefined) updateData.tags = tags + if (creatorId !== undefined) updateData.creatorId = creatorId + if (status !== undefined) updateData.status = status + + if (updateState && template.workflowId) { + const { verifyWorkflowAccess } = await import('@/socket/middleware/permissions') + const { hasAccess: hasWorkflowAccess } = await verifyWorkflowAccess( + session.user.id, + template.workflowId + ) - updateData.state = sanitizedState - updateData.requiredCredentials = requiredCredentials + if (!hasWorkflowAccess) { + logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`) + return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 }) + } - logger.info( - `[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}` + const { loadWorkflowFromNormalizedTables } = await import( + '@/lib/workflows/persistence/utils' ) - } else { - logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`) + const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId) + + if (normalizedData) { + const [workflowRecord] = await db + .select({ variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, template.workflowId)) + .limit(1) + + const currentState: Partial = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined, + lastSaved: Date.now(), + } + + const requiredCredentials = extractRequiredCredentials(currentState) + + const sanitizedState = sanitizeCredentials(currentState) + + updateData.state = sanitizedState + updateData.requiredCredentials = requiredCredentials + + logger.info( + `[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}` + ) + } else { + logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`) + } } - } - const updatedTemplate = await db - .update(templates) - .set(updateData) - .where(eq(templates.id, id)) - .returning() - - logger.info(`[${requestId}] Successfully updated template: ${id}`) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.TEMPLATE_UPDATED, - resourceType: AuditResourceType.TEMPLATE, - resourceId: id, - resourceName: name ?? template.name, - description: `Updated template "${name ?? template.name}"`, - request, - }) + const updatedTemplate = await db + .update(templates) + .set(updateData) + .where(eq(templates.id, id)) + .returning() + + logger.info(`[${requestId}] Successfully updated template: ${id}`) + + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.TEMPLATE_UPDATED, + resourceType: AuditResourceType.TEMPLATE, + resourceId: id, + resourceName: name ?? template.name, + description: `Updated template "${name ?? template.name}"`, + request, + }) - return NextResponse.json({ - data: updatedTemplate[0], - message: 'Template updated successfully', - }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + data: updatedTemplate[0], + message: 'Template updated successfully', + }) + } catch (error: any) { + logger.error(`[${requestId}] Error updating template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) // DELETE /api/templates/[id] - Delete a template -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - if (existing.length === 0) { - logger.warn(`[${requestId}] Template not found for delete: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } + const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) + if (existing.length === 0) { + logger.warn(`[${requestId}] Template not found for delete: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } - const template = existing[0] + const template = existing[0] - if (!template.creatorId) { - logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + if (!template.creatorId) { + logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - const { verifyCreatorPermission } = await import('@/lib/templates/permissions') - const { hasPermission, error: permissionError } = await verifyCreatorPermission( - session.user.id, - template.creatorId, - 'admin' - ) + const { verifyCreatorPermission } = await import('@/lib/templates/permissions') + const { hasPermission, error: permissionError } = await verifyCreatorPermission( + session.user.id, + template.creatorId, + 'admin' + ) - if (!hasPermission) { - logger.warn(`[${requestId}] User denied permission to delete template ${id}`) - return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 }) - } + if (!hasPermission) { + logger.warn(`[${requestId}] User denied permission to delete template ${id}`) + return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 }) + } - await db.delete(templates).where(eq(templates.id, id)) + await db.delete(templates).where(eq(templates.id, id)) - logger.info(`[${requestId}] Deleted template: ${id}`) + logger.info(`[${requestId}] Deleted template: ${id}`) - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.TEMPLATE_DELETED, - resourceType: AuditResourceType.TEMPLATE, - resourceId: id, - resourceName: template.name, - description: `Deleted template "${template.name}"`, - request, - }) + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.TEMPLATE_DELETED, + resourceType: AuditResourceType.TEMPLATE, + resourceId: id, + resourceName: template.name, + description: `Deleted template "${template.name}"`, + request, + }) - return NextResponse.json({ success: true }) - } catch (error: any) { - logger.error(`[${requestId}] Error deleting template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error: any) { + logger.error(`[${requestId}] Error deleting template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/templates/[id]/star/route.ts b/apps/sim/app/api/templates/[id]/star/route.ts index c57ba28eaf9..658f03c4cef 100644 --- a/apps/sim/app/api/templates/[id]/star/route.ts +++ b/apps/sim/app/api/templates/[id]/star/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TemplateStarAPI') @@ -13,156 +14,159 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 // GET /api/templates/[id]/star - Check if user has starred this template -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug( - `[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}` - ) - - // Check if the user has starred this template - const starRecord = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + logger.debug( + `[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}` + ) + + // Check if the user has starred this template + const starRecord = await db + .select({ id: templateStars.id }) + .from(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + .limit(1) - const isStarred = starRecord.length > 0 + const isStarred = starRecord.length > 0 - logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`) + logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`) - return NextResponse.json({ data: { isStarred } }) - } catch (error: any) { - logger.error(`[${requestId}] Error checking star status for template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ data: { isStarred } }) + } catch (error: any) { + logger.error(`[${requestId}] Error checking star status for template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) // POST /api/templates/[id]/star - Add a star to the template -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Verify the template exists - const templateExists = await db - .select({ id: templates.id }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (templateExists.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify the template exists + const templateExists = await db + .select({ id: templates.id }) + .from(templates) + .where(eq(templates.id, id)) + .limit(1) - // Check if user has already starred this template - const existingStar = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) + if (templateExists.length === 0) { + logger.warn(`[${requestId}] Template not found: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + } - if (existingStar.length > 0) { - logger.info(`[${requestId}] Template already starred: ${id}`) - return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) - } + // Check if user has already starred this template + const existingStar = await db + .select({ id: templateStars.id }) + .from(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + .limit(1) + + if (existingStar.length > 0) { + logger.info(`[${requestId}] Template already starred: ${id}`) + return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) + } + + // Use a transaction to ensure consistency + await db.transaction(async (tx) => { + // Add the star record + await tx.insert(templateStars).values({ + id: generateId(), + userId: session.user.id, + templateId: id, + starredAt: new Date(), + createdAt: new Date(), + }) - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Add the star record - await tx.insert(templateStars).values({ - id: generateId(), - userId: session.user.id, - templateId: id, - starredAt: new Date(), - createdAt: new Date(), + // Increment the star count + await tx + .update(templates) + .set({ + stars: sql`${templates.stars} + 1`, + }) + .where(eq(templates.id, id)) }) - // Increment the star count - await tx - .update(templates) - .set({ - stars: sql`${templates.stars} + 1`, - }) - .where(eq(templates.id, id)) - }) - - logger.info(`[${requestId}] Successfully starred template: ${id}`) - return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 }) - } catch (error: any) { - // Handle unique constraint violations gracefully - if (error.code === '23505') { - logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`) - return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) + logger.info(`[${requestId}] Successfully starred template: ${id}`) + return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 }) + } catch (error: any) { + // Handle unique constraint violations gracefully + if (error.code === '23505') { + logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`) + return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) + } + + logger.error(`[${requestId}] Error starring template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - logger.error(`[${requestId}] Error starring template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) // DELETE /api/templates/[id]/star - Remove a star from the template -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check if the star exists - const existingStar = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - if (existingStar.length === 0) { - logger.info(`[${requestId}] No star found to remove for template: ${id}`) - return NextResponse.json({ message: 'Template not starred' }, { status: 200 }) - } - - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Remove the star record - await tx - .delete(templateStars) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if the star exists + const existingStar = await db + .select({ id: templateStars.id }) + .from(templateStars) .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + .limit(1) + + if (existingStar.length === 0) { + logger.info(`[${requestId}] No star found to remove for template: ${id}`) + return NextResponse.json({ message: 'Template not starred' }, { status: 200 }) + } + + // Use a transaction to ensure consistency + await db.transaction(async (tx) => { + // Remove the star record + await tx + .delete(templateStars) + .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) + + // Decrement the star count (prevent negative values) + await tx + .update(templates) + .set({ + stars: sql`GREATEST(${templates.stars} - 1, 0)`, + }) + .where(eq(templates.id, id)) + }) - // Decrement the star count (prevent negative values) - await tx - .update(templates) - .set({ - stars: sql`GREATEST(${templates.stars} - 1, 0)`, - }) - .where(eq(templates.id, id)) - }) - - logger.info(`[${requestId}] Successfully unstarred template: ${id}`) - return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 }) - } catch (error: any) { - logger.error(`[${requestId}] Error unstarring template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.info(`[${requestId}] Successfully unstarred template: ${id}`) + return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 }) + } catch (error: any) { + logger.error(`[${requestId}] Error unstarring template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index a4c797381c1..9ba2def2c44 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -7,6 +7,7 @@ import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canAccessTemplate, verifyTemplateOwnership } from '@/lib/templates/permissions' import { type RegenerateStateInput, @@ -27,204 +28,206 @@ interface TemplateDetails { } // POST /api/templates/[id]/use - Use a template (increment views and create workflow) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - // Get workspace ID and connectToTemplate flag from request body - const body = await request.json() - const { workspaceId, connectToTemplate = false } = body + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId in request body`) - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } + // Get workspace ID and connectToTemplate flag from request body + const body = await request.json() + const { workspaceId, connectToTemplate = false } = body - const workspace = await getWorkspaceById(workspaceId) - if (!workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId in request body`) + return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) + } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const workspace = await getWorkspaceById(workspaceId) + if (!workspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - logger.debug( - `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}, connect: ${connectToTemplate}` - ) + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - // Get the template - const templateAccess = await canAccessTemplate(id, session.user.id) - if (!templateAccess.allowed) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } + logger.debug( + `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}, connect: ${connectToTemplate}` + ) - if (connectToTemplate) { - const ownership = await verifyTemplateOwnership(id, session.user.id, 'admin') - if (!ownership.authorized) { - return NextResponse.json( - { error: ownership.error || 'Access denied' }, - { status: ownership.status || 403 } - ) + // Get the template + const templateAccess = await canAccessTemplate(id, session.user.id) + if (!templateAccess.allowed) { + logger.warn(`[${requestId}] Template not found: ${id}`) + return NextResponse.json({ error: 'Template not found' }, { status: 404 }) } - } - const template = await db - .select({ - id: templates.id, - name: templates.name, - details: templates.details, - state: templates.state, - workflowId: templates.workflowId, - }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - const templateData = template[0] - - // Create a new workflow ID - const newWorkflowId = generateId() - const now = new Date() - - // Extract variables from the template state and remap to the new workflow - const templateVariables = (templateData.state as any)?.variables as - | Record - | undefined - const remappedVariables: Record = (() => { - if (!templateVariables || typeof templateVariables !== 'object') return {} - const mapped: Record = {} - for (const [, variable] of Object.entries(templateVariables)) { - const newVarId = generateId() - mapped[newVarId] = { ...variable, id: newVarId, workflowId: newWorkflowId } - } - return mapped - })() - - const rawName = - connectToTemplate && !templateData.workflowId - ? templateData.name - : `${templateData.name} (copy)` - const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null) - - await db.insert(workflow).values({ - id: newWorkflowId, - workspaceId: workspaceId, - name: dedupedName, - description: (templateData.details as TemplateDetails | null)?.tagline || null, - userId: session.user.id, - variables: remappedVariables, // Remap variable IDs and workflowId for the new workflow - createdAt: now, - updatedAt: now, - lastSynced: now, - isDeployed: connectToTemplate && !templateData.workflowId, - deployedAt: connectToTemplate && !templateData.workflowId ? now : null, - }) - - // Step 2: Regenerate IDs when creating a copy (not when connecting/editing template) - // When connecting to template (edit mode), keep original IDs - // When using template (copy mode), regenerate all IDs to avoid conflicts - const templateState = templateData.state as RegenerateStateInput - const workflowState = connectToTemplate - ? templateState - : regenerateWorkflowStateIds(templateState) - - // Step 3: Save the workflow state using the existing state endpoint (like imports do) - // Ensure variables in state are remapped for the new workflow as well - const workflowStateWithVariables = { ...workflowState, variables: remappedVariables } - const stateResponse = await fetch( - `${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - // Forward the session cookie for authentication - cookie: request.headers.get('cookie') || '', - }, - body: JSON.stringify(workflowStateWithVariables), + if (connectToTemplate) { + const ownership = await verifyTemplateOwnership(id, session.user.id, 'admin') + if (!ownership.authorized) { + return NextResponse.json( + { error: ownership.error || 'Access denied' }, + { status: ownership.status || 403 } + ) + } } - ) - if (!stateResponse.ok) { - logger.error(`[${requestId}] Failed to save workflow state for template use`) - // Clean up the workflow we created - await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) - return NextResponse.json( - { error: 'Failed to create workflow from template' }, - { status: 500 } - ) - } + const template = await db + .select({ + id: templates.id, + name: templates.name, + details: templates.details, + state: templates.state, + workflowId: templates.workflowId, + }) + .from(templates) + .where(eq(templates.id, id)) + .limit(1) + + const templateData = template[0] + + // Create a new workflow ID + const newWorkflowId = generateId() + const now = new Date() + + // Extract variables from the template state and remap to the new workflow + const templateVariables = (templateData.state as any)?.variables as + | Record + | undefined + const remappedVariables: Record = (() => { + if (!templateVariables || typeof templateVariables !== 'object') return {} + const mapped: Record = {} + for (const [, variable] of Object.entries(templateVariables)) { + const newVarId = generateId() + mapped[newVarId] = { ...variable, id: newVarId, workflowId: newWorkflowId } + } + return mapped + })() - // Use a transaction for template updates and deployment version - const result = await db.transaction(async (tx) => { - // Prepare template update data - const updateData: any = { - views: sql`${templates.views} + 1`, - } + const rawName = + connectToTemplate && !templateData.workflowId + ? templateData.name + : `${templateData.name} (copy)` + const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null) + + await db.insert(workflow).values({ + id: newWorkflowId, + workspaceId: workspaceId, + name: dedupedName, + description: (templateData.details as TemplateDetails | null)?.tagline || null, + userId: session.user.id, + variables: remappedVariables, // Remap variable IDs and workflowId for the new workflow + createdAt: now, + updatedAt: now, + lastSynced: now, + isDeployed: connectToTemplate && !templateData.workflowId, + deployedAt: connectToTemplate && !templateData.workflowId ? now : null, + }) - // If connecting to template for editing, also update the workflowId - // Also create a new deployment version for this workflow with the same state - if (connectToTemplate && !templateData.workflowId) { - updateData.workflowId = newWorkflowId - - // Create a deployment version for the new workflow - if (templateData.state) { - const newDeploymentVersionId = generateId() - await tx.insert(workflowDeploymentVersion).values({ - id: newDeploymentVersionId, - workflowId: newWorkflowId, - version: 1, - state: templateData.state, - isActive: true, - createdAt: now, - createdBy: session.user.id, - }) + // Step 2: Regenerate IDs when creating a copy (not when connecting/editing template) + // When connecting to template (edit mode), keep original IDs + // When using template (copy mode), regenerate all IDs to avoid conflicts + const templateState = templateData.state as RegenerateStateInput + const workflowState = connectToTemplate + ? templateState + : regenerateWorkflowStateIds(templateState) + + // Step 3: Save the workflow state using the existing state endpoint (like imports do) + // Ensure variables in state are remapped for the new workflow as well + const workflowStateWithVariables = { ...workflowState, variables: remappedVariables } + const stateResponse = await fetch( + `${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + // Forward the session cookie for authentication + cookie: request.headers.get('cookie') || '', + }, + body: JSON.stringify(workflowStateWithVariables), } + ) + + if (!stateResponse.ok) { + logger.error(`[${requestId}] Failed to save workflow state for template use`) + // Clean up the workflow we created + await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) + return NextResponse.json( + { error: 'Failed to create workflow from template' }, + { status: 500 } + ) } - // Update template with view count and potentially new workflow connection - await tx.update(templates).set(updateData).where(eq(templates.id, id)) + // Use a transaction for template updates and deployment version + const result = await db.transaction(async (tx) => { + // Prepare template update data + const updateData: any = { + views: sql`${templates.views} + 1`, + } - return { id: newWorkflowId } - }) + // If connecting to template for editing, also update the workflowId + // Also create a new deployment version for this workflow with the same state + if (connectToTemplate && !templateData.workflowId) { + updateData.workflowId = newWorkflowId + + // Create a deployment version for the new workflow + if (templateData.state) { + const newDeploymentVersionId = generateId() + await tx.insert(workflowDeploymentVersion).values({ + id: newDeploymentVersionId, + workflowId: newWorkflowId, + version: 1, + state: templateData.state, + isActive: true, + createdAt: now, + createdBy: session.user.id, + }) + } + } - logger.info( - `[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}` - ) + // Update template with view count and potentially new workflow connection + await tx.update(templates).set(updateData).where(eq(templates.id, id)) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - const templateState = templateData.state as any - PlatformEvents.templateUsed({ - templateId: id, - templateName: templateData.name, - newWorkflowId, - blocksCount: templateState?.blocks ? Object.keys(templateState.blocks).length : 0, - workspaceId, + return { id: newWorkflowId } }) - } catch (_e) { - // Silently fail - } - return NextResponse.json( - { - message: 'Template used successfully', - workflowId: newWorkflowId, - workspaceId: workspaceId, - }, - { status: 201 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error using template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.info( + `[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}` + ) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + const templateState = templateData.state as any + PlatformEvents.templateUsed({ + templateId: id, + templateName: templateData.name, + newWorkflowId, + blocksCount: templateState?.blocks ? Object.keys(templateState.blocks).length : 0, + workspaceId, + }) + } catch (_e) { + // Silently fail + } + + return NextResponse.json( + { + message: 'Template used successfully', + workflowId: newWorkflowId, + workspaceId: workspaceId, + }, + { status: 201 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Error using template: ${id}`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts index 2b6fad9652a..50ae8e3be93 100644 --- a/apps/sim/app/api/templates/approved/sanitized/route.ts +++ b/apps/sim/app/api/templates/approved/sanitized/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalApiKey } from '@/lib/copilot/utils' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' const logger = createLogger('TemplatesSanitizedAPI') @@ -16,7 +17,7 @@ export const revalidate = 0 * Returns all approved templates with their sanitized JSONs, names, and descriptions * Requires internal API secret authentication via X-API-Key header */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -123,10 +124,10 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) // Add a helpful OPTIONS handler for CORS preflight -export async function OPTIONS(request: NextRequest) { +export const OPTIONS = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] OPTIONS request received for /api/templates/approved/sanitized`) @@ -137,4 +138,4 @@ export async function OPTIONS(request: NextRequest) { 'Access-Control-Allow-Headers': 'X-API-Key, Content-Type', }, }) -} +}) diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index c6424865c82..c7c8ca0ff30 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -14,6 +14,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { canAccessTemplate, verifyEffectiveSuperUser } from '@/lib/templates/permissions' import { extractRequiredCredentials, @@ -56,7 +57,7 @@ const QueryParamsSchema = z.object({ }) // GET /api/templates - Retrieve templates -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -220,10 +221,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching templates`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) // POST /api/templates - Create a new template -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -368,4 +369,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error creating template`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/cancel-task/route.ts b/apps/sim/app/api/tools/a2a/cancel-task/route.ts index ec321153eb7..4c349282310 100644 --- a/apps/sim/app/api/tools/a2a/cancel-task/route.ts +++ b/apps/sim/app/api/tools/a2a/cancel-task/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('A2ACancelTaskAPI') @@ -16,7 +17,7 @@ const A2ACancelTaskSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -81,4 +82,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts b/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts index a328648528a..a2224719608 100644 --- a/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/delete-push-notification/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -16,7 +17,7 @@ const A2ADeletePushNotificationSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -91,4 +92,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/get-agent-card/route.ts b/apps/sim/app/api/tools/a2a/get-agent-card/route.ts index 12b8d7f142d..fd988043318 100644 --- a/apps/sim/app/api/tools/a2a/get-agent-card/route.ts +++ b/apps/sim/app/api/tools/a2a/get-agent-card/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const A2AGetAgentCardSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -89,4 +90,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/get-push-notification/route.ts b/apps/sim/app/api/tools/a2a/get-push-notification/route.ts index 1295e3158eb..9b6bacd415a 100644 --- a/apps/sim/app/api/tools/a2a/get-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/get-push-notification/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const A2AGetPushNotificationSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -112,4 +113,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/get-task/route.ts b/apps/sim/app/api/tools/a2a/get-task/route.ts index d71384d6593..635aa39bee4 100644 --- a/apps/sim/app/api/tools/a2a/get-task/route.ts +++ b/apps/sim/app/api/tools/a2a/get-task/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { createA2AClient } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ const A2AGetTaskSchema = z.object({ historyLength: z.number().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -92,4 +93,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/resubscribe/route.ts b/apps/sim/app/api/tools/a2a/resubscribe/route.ts index 6f935f2f719..8b7fa0198d8 100644 --- a/apps/sim/app/api/tools/a2a/resubscribe/route.ts +++ b/apps/sim/app/api/tools/a2a/resubscribe/route.ts @@ -12,6 +12,7 @@ import { z } from 'zod' import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('A2AResubscribeAPI') @@ -23,7 +24,7 @@ const A2AResubscribeSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -116,4 +117,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/send-message/route.ts b/apps/sim/app/api/tools/a2a/send-message/route.ts index c15d1921a31..9b5925de8c4 100644 --- a/apps/sim/app/api/tools/a2a/send-message/route.ts +++ b/apps/sim/app/api/tools/a2a/send-message/route.ts @@ -7,6 +7,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -29,7 +30,7 @@ const A2ASendMessageSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -224,4 +225,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts index 986161882ce..27890837897 100644 --- a/apps/sim/app/api/tools/a2a/set-push-notification/route.ts +++ b/apps/sim/app/api/tools/a2a/set-push-notification/route.ts @@ -5,6 +5,7 @@ import { createA2AClient } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ const A2ASetPushNotificationSchema = z.object({ apiKey: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -103,4 +104,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts index 839c1359dd3..12e5486e2d3 100644 --- a/apps/sim/app/api/tools/airtable/bases/route.ts +++ b/apps/sim/app/api/tools/airtable/bases/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('AirtableBasesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -76,4 +77,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts index 41cd68dc12f..be7ba8e38eb 100644 --- a/apps/sim/app/api/tools/airtable/tables/route.ts +++ b/apps/sim/app/api/tools/airtable/tables/route.ts @@ -3,13 +3,14 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAirtableId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('AirtableTablesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -92,4 +93,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/add-comment/route.ts b/apps/sim/app/api/tools/asana/add-comment/route.ts index 2fbd49937b4..188475dddeb 100644 --- a/apps/sim/app/api/tools/asana/add-comment/route.ts +++ b/apps/sim/app/api/tools/asana/add-comment/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('AsanaAddCommentAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -113,4 +114,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/create-task/route.ts b/apps/sim/app/api/tools/asana/create-task/route.ts index 7d82ff3e47f..8bd3814a22b 100644 --- a/apps/sim/app/api/tools/asana/create-task/route.ts +++ b/apps/sim/app/api/tools/asana/create-task/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('AsanaCreateTaskAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -127,4 +128,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/get-projects/route.ts b/apps/sim/app/api/tools/asana/get-projects/route.ts index 10513a4c8da..d3b175c2a01 100644 --- a/apps/sim/app/api/tools/asana/get-projects/route.ts +++ b/apps/sim/app/api/tools/asana/get-projects/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('AsanaGetProjectsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -96,4 +97,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/get-task/route.ts b/apps/sim/app/api/tools/asana/get-task/route.ts index 1a2a9a81fd8..045d41f4090 100644 --- a/apps/sim/app/api/tools/asana/get-task/route.ts +++ b/apps/sim/app/api/tools/asana/get-task/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('AsanaGetTaskAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -221,4 +222,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/search-tasks/route.ts b/apps/sim/app/api/tools/asana/search-tasks/route.ts index a9446bd1152..5a7d6141be9 100644 --- a/apps/sim/app/api/tools/asana/search-tasks/route.ts +++ b/apps/sim/app/api/tools/asana/search-tasks/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('AsanaSearchTasksAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -139,4 +140,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/update-task/route.ts b/apps/sim/app/api/tools/asana/update-task/route.ts index 8d990cf58b1..1a799f0b52a 100644 --- a/apps/sim/app/api/tools/asana/update-task/route.ts +++ b/apps/sim/app/api/tools/asana/update-task/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('AsanaUpdateTaskAPI') -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -126,4 +127,4 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts index 2393ade11c9..1ecbe151c08 100644 --- a/apps/sim/app/api/tools/asana/workspaces/route.ts +++ b/apps/sim/app/api/tools/asana/workspaces/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('AsanaWorkspacesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -76,4 +77,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/attio/lists/route.ts b/apps/sim/app/api/tools/attio/lists/route.ts index 1575f7eb3a0..ba872dc143c 100644 --- a/apps/sim/app/api/tools/attio/lists/route.ts +++ b/apps/sim/app/api/tools/attio/lists/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('AttioListsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -76,4 +77,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/attio/objects/route.ts b/apps/sim/app/api/tools/attio/objects/route.ts index ae3ba5152dd..78bd1b1ffde 100644 --- a/apps/sim/app/api/tools/attio/objects/route.ts +++ b/apps/sim/app/api/tools/attio/objects/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('AttioObjectsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -76,4 +77,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/box/upload/route.ts b/apps/sim/app/api/tools/box/upload/route.ts index f99ade2e844..3d1bd9b613a 100644 --- a/apps/sim/app/api/tools/box/upload/route.ts +++ b/apps/sim/app/api/tools/box/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -19,7 +20,7 @@ const BoxUploadSchema = z.object({ fileName: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -137,4 +138,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/calcom/event-types/route.ts b/apps/sim/app/api/tools/calcom/event-types/route.ts index b8596f614f8..74c3b1b1481 100644 --- a/apps/sim/app/api/tools/calcom/event-types/route.ts +++ b/apps/sim/app/api/tools/calcom/event-types/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('CalcomEventTypesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -80,4 +81,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/calcom/schedules/route.ts b/apps/sim/app/api/tools/calcom/schedules/route.ts index 8f69328cc65..108c1540b25 100644 --- a/apps/sim/app/api/tools/calcom/schedules/route.ts +++ b/apps/sim/app/api/tools/calcom/schedules/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('CalcomSchedulesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -77,4 +78,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts b/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts index d267bb01fa3..86ebfe76cbc 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stack-drift-detection-status/route.ts @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDescribeStackDriftDetectionStatus') @@ -16,7 +17,7 @@ const DescribeStackDriftDetectionStatusSchema = z.object({ stackDriftDetectionId: z.string().min(1, 'Stack drift detection ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -64,4 +65,4 @@ export async function POST(request: NextRequest) { logger.error('DescribeStackDriftDetectionStatus failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts b/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts index a3108eebffc..a7173ed7282 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stack-events/route.ts @@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDescribeStackEvents') @@ -21,7 +22,7 @@ const DescribeStackEventsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -81,4 +82,4 @@ export async function POST(request: NextRequest) { logger.error('DescribeStackEvents failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts b/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts index d8fc946b519..50e515abcc7 100644 --- a/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts +++ b/apps/sim/app/api/tools/cloudformation/describe-stacks/route.ts @@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDescribeStacks') @@ -17,7 +18,7 @@ const DescribeStacksSchema = z.object({ stackName: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -89,4 +90,4 @@ export async function POST(request: NextRequest) { logger.error('DescribeStacks failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts b/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts index a21c3e70410..0f23c1aced6 100644 --- a/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts +++ b/apps/sim/app/api/tools/cloudformation/detect-stack-drift/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationDetectStackDrift') @@ -13,7 +14,7 @@ const DetectStackDriftSchema = z.object({ stackName: z.string().min(1, 'Stack name is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { logger.error('DetectStackDrift failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/get-template/route.ts b/apps/sim/app/api/tools/cloudformation/get-template/route.ts index a5e6edeeaa3..46d72b28ae1 100644 --- a/apps/sim/app/api/tools/cloudformation/get-template/route.ts +++ b/apps/sim/app/api/tools/cloudformation/get-template/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationGetTemplate') @@ -13,7 +14,7 @@ const GetTemplateSchema = z.object({ stackName: z.string().min(1, 'Stack name is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -56,4 +57,4 @@ export async function POST(request: NextRequest) { logger.error('GetTemplate failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts b/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts index dfc65171362..1f6ad8fa24e 100644 --- a/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts +++ b/apps/sim/app/api/tools/cloudformation/list-stack-resources/route.ts @@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationListStackResources') @@ -17,7 +18,7 @@ const ListStackResourcesSchema = z.object({ stackName: z.string().min(1, 'Stack name is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -78,4 +79,4 @@ export async function POST(request: NextRequest) { logger.error('ListStackResources failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudformation/validate-template/route.ts b/apps/sim/app/api/tools/cloudformation/validate-template/route.ts index 1264d813fdf..c526ce267e7 100644 --- a/apps/sim/app/api/tools/cloudformation/validate-template/route.ts +++ b/apps/sim/app/api/tools/cloudformation/validate-template/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudFormationValidateTemplate') @@ -13,7 +14,7 @@ const ValidateTemplateSchema = z.object({ templateBody: z.string().min(1, 'Template body is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -64,4 +65,4 @@ export async function POST(request: NextRequest) { logger.error('ValidateTemplate failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts index 3fc65ab5bfd..f20795aa6a0 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchDescribeAlarms') @@ -30,7 +31,7 @@ const DescribeAlarmsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -99,4 +100,4 @@ export async function POST(request: NextRequest) { logger.error('DescribeAlarms failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts index fcb29be5289..d72f5d0ddb4 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchDescribeLogGroups') @@ -18,7 +19,7 @@ const DescribeLogGroupsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -65,4 +66,4 @@ export async function POST(request: NextRequest) { logger.error('DescribeLogGroups failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts index 223e51617b9..ceda4c95b31 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchDescribeLogStreams') @@ -18,7 +19,7 @@ const DescribeLogStreamsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -55,4 +56,4 @@ export async function POST(request: NextRequest) { logger.error('DescribeLogStreams failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts b/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts index e1a8abcf666..c6413da8f0c 100644 --- a/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchGetLogEvents') @@ -20,7 +21,7 @@ const GetLogEventsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -63,4 +64,4 @@ export async function POST(request: NextRequest) { logger.error('GetLogEvents failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts b/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts index 677bafca3ca..73627eb6100 100644 --- a/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchGetMetricStatistics') @@ -19,7 +20,7 @@ const GetMetricStatisticsSchema = z.object({ dimensions: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -100,4 +101,4 @@ export async function POST(request: NextRequest) { logger.error('GetMetricStatistics failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts b/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts index 36d2c31e2fa..7d342d41fb5 100644 --- a/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CloudWatchListMetrics') @@ -19,7 +20,7 @@ const ListMetricsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -73,4 +74,4 @@ export async function POST(request: NextRequest) { logger.error('ListMetrics failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts b/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts index 75b4dab2395..01a0b753220 100644 --- a/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/query-logs/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchQueryLogs') @@ -21,7 +22,7 @@ const QueryLogsSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -74,4 +75,4 @@ export async function POST(request: NextRequest) { logger.error('QueryLogs failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/attachment/route.ts b/apps/sim/app/api/tools/confluence/attachment/route.ts index f0265fe1f29..ff707c0b210 100644 --- a/apps/sim/app/api/tools/confluence/attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/attachment/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceAttachmentAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluenceAttachmentAPI') export const dynamic = 'force-dynamic' // Delete an attachment -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -72,4 +73,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/attachments/route.ts b/apps/sim/app/api/tools/confluence/attachments/route.ts index 4d25840bde9..98eb74ad705 100644 --- a/apps/sim/app/api/tools/confluence/attachments/route.ts +++ b/apps/sim/app/api/tools/confluence/attachments/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceAttachmentsAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluenceAttachmentsAPI') export const dynamic = 'force-dynamic' // List attachments on a page -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -104,4 +105,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/blogposts/route.ts b/apps/sim/app/api/tools/confluence/blogposts/route.ts index cdfa12f5935..839b0fa6b92 100644 --- a/apps/sim/app/api/tools/confluence/blogposts/route.ts +++ b/apps/sim/app/api/tools/confluence/blogposts/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceBlogPostsAPI') @@ -41,7 +42,7 @@ const createBlogPostSchema = z.object({ /** * List all blog posts or get a specific blog post */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -134,12 +135,12 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) /** * Get a specific blog post by ID */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -282,12 +283,12 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) /** * Update a blog post */ -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -381,12 +382,12 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) /** * Delete a blog post */ -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -444,4 +445,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/comment/route.ts b/apps/sim/app/api/tools/confluence/comment/route.ts index 3486f975a5d..ddbfd9568b1 100644 --- a/apps/sim/app/api/tools/confluence/comment/route.ts +++ b/apps/sim/app/api/tools/confluence/comment/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceCommentAPI') @@ -47,7 +48,7 @@ const deleteCommentSchema = z ) // Update a comment -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -131,10 +132,10 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) // Delete a comment -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -188,4 +189,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/comments/route.ts b/apps/sim/app/api/tools/confluence/comments/route.ts index 8c7b03ac044..5c44abaae7a 100644 --- a/apps/sim/app/api/tools/confluence/comments/route.ts +++ b/apps/sim/app/api/tools/confluence/comments/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceCommentsAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluenceCommentsAPI') export const dynamic = 'force-dynamic' // Create a comment -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -89,10 +90,10 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) // List comments on a page -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -194,4 +195,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/create-page/route.ts b/apps/sim/app/api/tools/confluence/create-page/route.ts index c11dbbc7f62..ae5fe64a54b 100644 --- a/apps/sim/app/api/tools/confluence/create-page/route.ts +++ b/apps/sim/app/api/tools/confluence/create-page/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceCreatePageAPI') export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -137,4 +138,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/labels/route.ts b/apps/sim/app/api/tools/confluence/labels/route.ts index 020811ff653..09ff066fd87 100644 --- a/apps/sim/app/api/tools/confluence/labels/route.ts +++ b/apps/sim/app/api/tools/confluence/labels/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceLabelsAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluenceLabelsAPI') export const dynamic = 'force-dynamic' // Add a label to a page -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -100,10 +101,10 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) // List labels on a page -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -190,10 +191,10 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) // Delete a label from a page -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -271,4 +272,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/page-ancestors/route.ts b/apps/sim/app/api/tools/confluence/page-ancestors/route.ts index 743cce75a5f..9eff75fadd2 100644 --- a/apps/sim/app/api/tools/confluence/page-ancestors/route.ts +++ b/apps/sim/app/api/tools/confluence/page-ancestors/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageAncestorsAPI') @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic' * Get ancestors (parent pages) of a specific Confluence page. * Uses GET /wiki/api/v2/pages/{id}/ancestors */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -93,4 +94,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/page-children/route.ts b/apps/sim/app/api/tools/confluence/page-children/route.ts index 7cd7a41bd0e..caa218a279b 100644 --- a/apps/sim/app/api/tools/confluence/page-children/route.ts +++ b/apps/sim/app/api/tools/confluence/page-children/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageChildrenAPI') @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic' * Get child pages of a specific Confluence page. * Uses GET /wiki/api/v2/pages/{id}/children */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -101,4 +102,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/page-descendants/route.ts b/apps/sim/app/api/tools/confluence/page-descendants/route.ts index e437b737503..43ebf1f21ba 100644 --- a/apps/sim/app/api/tools/confluence/page-descendants/route.ts +++ b/apps/sim/app/api/tools/confluence/page-descendants/route.ts @@ -6,6 +6,7 @@ import { validateJiraCloudId, validatePaginationCursor, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageDescendantsAPI') @@ -16,7 +17,7 @@ export const dynamic = 'force-dynamic' * Get all descendants of a Confluence page recursively. * Uses GET /wiki/api/v2/pages/{id}/descendants */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -112,4 +113,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/page-properties/route.ts b/apps/sim/app/api/tools/confluence/page-properties/route.ts index f8c3ce0ee29..3115f0a6727 100644 --- a/apps/sim/app/api/tools/confluence/page-properties/route.ts +++ b/apps/sim/app/api/tools/confluence/page-properties/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePagePropertiesAPI') @@ -40,7 +41,7 @@ const deletePropertySchema = z.object({ /** * List all content properties on a page. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -129,12 +130,12 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) /** * Create a new content property on a page. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -202,12 +203,12 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) /** * Update a content property on a page. */ -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -293,12 +294,12 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) /** * Delete a content property from a page. */ -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -362,4 +363,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/page-versions/route.ts b/apps/sim/app/api/tools/confluence/page-versions/route.ts index 799fc28b0a9..35020f7a28d 100644 --- a/apps/sim/app/api/tools/confluence/page-versions/route.ts +++ b/apps/sim/app/api/tools/confluence/page-versions/route.ts @@ -7,6 +7,7 @@ import { validateNumericId, validatePaginationCursor, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cleanHtmlContent, getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageVersionsAPI') @@ -18,7 +19,7 @@ export const dynamic = 'force-dynamic' * Uses GET /wiki/api/v2/pages/{id}/versions * and GET /wiki/api/v2/pages/{page-id}/versions/{version-number} */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -200,4 +201,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/page/route.ts b/apps/sim/app/api/tools/confluence/page/route.ts index 191ddcef6f6..9f0a8d044e7 100644 --- a/apps/sim/app/api/tools/confluence/page/route.ts +++ b/apps/sim/app/api/tools/confluence/page/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePageAPI') @@ -75,7 +76,7 @@ const deletePageSchema = z } ) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -151,9 +152,9 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -260,9 +261,9 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -321,4 +322,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/pages-by-label/route.ts b/apps/sim/app/api/tools/confluence/pages-by-label/route.ts index bef62261690..dce62c67e2b 100644 --- a/apps/sim/app/api/tools/confluence/pages-by-label/route.ts +++ b/apps/sim/app/api/tools/confluence/pages-by-label/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePagesByLabelAPI') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -100,4 +101,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/pages/route.ts b/apps/sim/app/api/tools/confluence/pages/route.ts index 739dc06591c..8997db3cc21 100644 --- a/apps/sim/app/api/tools/confluence/pages/route.ts +++ b/apps/sim/app/api/tools/confluence/pages/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluencePagesAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluencePagesAPI') export const dynamic = 'force-dynamic' // List pages or search pages -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -117,4 +118,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/search-in-space/route.ts b/apps/sim/app/api/tools/confluence/search-in-space/route.ts index 8a3dcf1a159..49e15e945ef 100644 --- a/apps/sim/app/api/tools/confluence/search-in-space/route.ts +++ b/apps/sim/app/api/tools/confluence/search-in-space/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSearchInSpaceAPI') @@ -11,7 +12,7 @@ export const dynamic = 'force-dynamic' /** * Search for content within a specific Confluence space using CQL. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -117,4 +118,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/search/route.ts b/apps/sim/app/api/tools/confluence/search/route.ts index f31f992797f..42c364956b1 100644 --- a/apps/sim/app/api/tools/confluence/search/route.ts +++ b/apps/sim/app/api/tools/confluence/search/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' export const dynamic = 'force-dynamic' const logger = createLogger('Confluence Search') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -102,4 +103,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts index 7ae61d3e983..65d3d5b5e18 100644 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils' @@ -10,7 +11,7 @@ const logger = createLogger('ConfluenceSelectorSpacesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -93,4 +94,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/space-blogposts/route.ts b/apps/sim/app/api/tools/confluence/space-blogposts/route.ts index 4607f9f57e7..6b887135ace 100644 --- a/apps/sim/app/api/tools/confluence/space-blogposts/route.ts +++ b/apps/sim/app/api/tools/confluence/space-blogposts/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpaceBlogPostsAPI') @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic' * List all blog posts in a specific Confluence space. * Uses GET /wiki/api/v2/spaces/{id}/blogposts */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -121,4 +122,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/space-labels/route.ts b/apps/sim/app/api/tools/confluence/space-labels/route.ts index be28cd2c92d..30cc98752ec 100644 --- a/apps/sim/app/api/tools/confluence/space-labels/route.ts +++ b/apps/sim/app/api/tools/confluence/space-labels/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpaceLabelsAPI') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -95,4 +96,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/space-pages/route.ts b/apps/sim/app/api/tools/confluence/space-pages/route.ts index fcf17efa0cf..f05a54eeba4 100644 --- a/apps/sim/app/api/tools/confluence/space-pages/route.ts +++ b/apps/sim/app/api/tools/confluence/space-pages/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpacePagesAPI') @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic' * List all pages in a specific Confluence space. * Uses GET /wiki/api/v2/spaces/{id}/pages */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -122,4 +123,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/space-permissions/route.ts b/apps/sim/app/api/tools/confluence/space-permissions/route.ts index 8a046a0f6c3..267676d84fc 100644 --- a/apps/sim/app/api/tools/confluence/space-permissions/route.ts +++ b/apps/sim/app/api/tools/confluence/space-permissions/route.ts @@ -6,6 +6,7 @@ import { validateJiraCloudId, validatePaginationCursor, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpacePermissionsAPI') @@ -16,7 +17,7 @@ export const dynamic = 'force-dynamic' * List permissions for a Confluence space. * Uses GET /wiki/api/v2/spaces/{id}/permissions */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -111,4 +112,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/space-properties/route.ts b/apps/sim/app/api/tools/confluence/space-properties/route.ts index 5bdd176aff8..33c54cff30f 100644 --- a/apps/sim/app/api/tools/confluence/space-properties/route.ts +++ b/apps/sim/app/api/tools/confluence/space-properties/route.ts @@ -6,6 +6,7 @@ import { validateJiraCloudId, validatePaginationCursor, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpacePropertiesAPI') @@ -17,7 +18,7 @@ export const dynamic = 'force-dynamic' * Uses GET/POST /wiki/api/v2/spaces/{id}/properties * and DELETE /wiki/api/v2/spaces/{id}/properties/{propertyId} */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -206,4 +207,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/space/route.ts b/apps/sim/app/api/tools/confluence/space/route.ts index ffe58f037a0..2100aed477a 100644 --- a/apps/sim/app/api/tools/confluence/space/route.ts +++ b/apps/sim/app/api/tools/confluence/space/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpaceAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluenceSpaceAPI') export const dynamic = 'force-dynamic' // Get a specific space -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -77,13 +78,13 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) /** * Create a new Confluence space. * Uses POST /wiki/api/v2/spaces */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -155,13 +156,13 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) /** * Update a Confluence space. * Uses PUT /wiki/api/v2/spaces/{id} */ -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -261,13 +262,13 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) /** * Delete a Confluence space. * Uses DELETE /wiki/api/v2/spaces/{id} */ -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -332,4 +333,4 @@ export async function DELETE(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/spaces/route.ts b/apps/sim/app/api/tools/confluence/spaces/route.ts index 0ce8dd0ee81..9a9b2d53982 100644 --- a/apps/sim/app/api/tools/confluence/spaces/route.ts +++ b/apps/sim/app/api/tools/confluence/spaces/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceSpacesAPI') @@ -9,7 +10,7 @@ const logger = createLogger('ConfluenceSpacesAPI') export const dynamic = 'force-dynamic' // List all spaces -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -92,4 +93,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/tasks/route.ts b/apps/sim/app/api/tools/confluence/tasks/route.ts index e74ca3b5400..a836280f3fa 100644 --- a/apps/sim/app/api/tools/confluence/tasks/route.ts +++ b/apps/sim/app/api/tools/confluence/tasks/route.ts @@ -7,6 +7,7 @@ import { validatePaginationCursor, validatePathSegment, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceTasksAPI') @@ -17,7 +18,7 @@ export const dynamic = 'force-dynamic' * List, get, or update Confluence inline tasks. * Uses GET /wiki/api/v2/tasks, GET /wiki/api/v2/tasks/{id}, PUT /wiki/api/v2/tasks/{id} */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -275,4 +276,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts index 599d70b7540..7e96bd88e16 100644 --- a/apps/sim/app/api/tools/confluence/upload-attachment/route.ts +++ b/apps/sim/app/api/tools/confluence/upload-attachment/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { getConfluenceCloudId } from '@/tools/confluence/utils' @@ -10,7 +11,7 @@ const logger = createLogger('ConfluenceUploadAttachmentAPI') export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -141,4 +142,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/confluence/user/route.ts b/apps/sim/app/api/tools/confluence/user/route.ts index 5b81116d804..b730fc548c9 100644 --- a/apps/sim/app/api/tools/confluence/user/route.ts +++ b/apps/sim/app/api/tools/confluence/user/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validatePathSegment } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getConfluenceCloudId } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceUserAPI') @@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic' * Get a Confluence user by account ID. * Uses GET /wiki/rest/api/user?accountId={accountId} */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -82,4 +83,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/cursor/download-artifact/route.ts b/apps/sim/app/api/tools/cursor/download-artifact/route.ts index bc185d1d86a..329e1bcb65e 100644 --- a/apps/sim/app/api/tools/cursor/download-artifact/route.ts +++ b/apps/sim/app/api/tools/cursor/download-artifact/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ const DownloadArtifactSchema = z.object({ path: z.string().min(1, 'Artifact path is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -143,4 +144,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 7d45353e609..bcbf04b980a 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -39,7 +40,7 @@ const CustomToolSchema = z.object({ }) // GET - Fetch all custom tools for the workspace -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const workspaceId = searchParams.get('workspaceId') @@ -118,10 +119,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching custom tools:`, error) return NextResponse.json({ error: 'Failed to fetch custom tools' }, { status: 500 }) } -} +}) // POST - Create or update custom tools -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -209,10 +210,10 @@ export async function POST(req: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'Failed to update custom tools' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) // DELETE - Delete a custom tool by ID -export async function DELETE(request: NextRequest) { +export const DELETE = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const toolId = searchParams.get('id') @@ -316,4 +317,4 @@ export async function DELETE(request: NextRequest) { logger.error(`[${requestId}] Error deleting custom tool:`, error) return NextResponse.json({ error: 'Failed to delete custom tool' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/discord/channels/route.ts b/apps/sim/app/api/tools/discord/channels/route.ts index f20735af7cf..e254b7e1a5c 100644 --- a/apps/sim/app/api/tools/discord/channels/route.ts +++ b/apps/sim/app/api/tools/discord/channels/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' interface DiscordChannel { id: string @@ -14,7 +15,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('DiscordChannelsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -143,4 +144,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/discord/send-message/route.ts b/apps/sim/app/api/tools/discord/send-message/route.ts index f5bf7d27f34..3af7e2876bc 100644 --- a/apps/sim/app/api/tools/discord/send-message/route.ts +++ b/apps/sim/app/api/tools/discord/send-message/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -19,7 +20,7 @@ const DiscordSendMessageSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -199,4 +200,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/discord/servers/route.ts b/apps/sim/app/api/tools/discord/servers/route.ts index 490bc4d0af4..e853e3e7291 100644 --- a/apps/sim/app/api/tools/discord/servers/route.ts +++ b/apps/sim/app/api/tools/discord/servers/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateNumericId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' interface DiscordServer { id: string @@ -13,7 +14,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('DiscordServersAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -89,4 +90,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/docusign/route.ts b/apps/sim/app/api/tools/docusign/route.ts index a808bfe0fdc..fa7344928a2 100644 --- a/apps/sim/app/api/tools/docusign/route.ts +++ b/apps/sim/app/api/tools/docusign/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -49,7 +50,7 @@ async function resolveAccount(accessToken: string): Promise } } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -102,7 +103,7 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : 'Internal server error' return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) async function handleSendEnvelope( apiBase: string, diff --git a/apps/sim/app/api/tools/drive/file/route.ts b/apps/sim/app/api/tools/drive/file/route.ts index 78fc6bdce91..0b1ad0fc675 100644 --- a/apps/sim/app/api/tools/drive/file/route.ts +++ b/apps/sim/app/api/tools/drive/file/route.ts @@ -4,6 +4,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -13,7 +14,7 @@ const logger = createLogger('GoogleDriveFileAPI') /** * Get a single file from Google Drive */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Google Drive file request received`) @@ -168,4 +169,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching file from Google Drive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts index 2cdb0505ebc..64721fe7a6c 100644 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ b/apps/sim/app/api/tools/drive/files/route.ts @@ -4,6 +4,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -70,7 +71,7 @@ async function fetchSharedDrives(accessToken: string, requestId: string): Promis } } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Google Drive files request received`) @@ -186,4 +187,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching files from Google Drive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/dropbox/upload/route.ts b/apps/sim/app/api/tools/dropbox/upload/route.ts index bdf06a5c63e..7bd4a888c4a 100644 --- a/apps/sim/app/api/tools/dropbox/upload/route.ts +++ b/apps/sim/app/api/tools/dropbox/upload/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { httpHeaderSafeJson } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -24,7 +25,7 @@ const DropboxUploadSchema = z.object({ mute: z.boolean().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -129,4 +130,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/delete/route.ts b/apps/sim/app/api/tools/dynamodb/delete/route.ts index 5b6ab1d5b20..2915b96ede6 100644 --- a/apps/sim/app/api/tools/dynamodb/delete/route.ts +++ b/apps/sim/app/api/tools/dynamodb/delete/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, deleteItem } from '@/app/api/tools/dynamodb/utils' const DeleteSchema = z.object({ @@ -14,7 +15,7 @@ const DeleteSchema = z.object({ conditionExpression: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -50,4 +51,4 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'DynamoDB delete failed' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/get/route.ts b/apps/sim/app/api/tools/dynamodb/get/route.ts index 1eca9d3f72e..173f958aafe 100644 --- a/apps/sim/app/api/tools/dynamodb/get/route.ts +++ b/apps/sim/app/api/tools/dynamodb/get/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, getItem } from '@/app/api/tools/dynamodb/utils' const GetSchema = z.object({ @@ -20,7 +21,7 @@ const GetSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -57,4 +58,4 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'DynamoDB get failed' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/introspect/route.ts b/apps/sim/app/api/tools/dynamodb/introspect/route.ts index bad2d517ca7..d3a00b9aaec 100644 --- a/apps/sim/app/api/tools/dynamodb/introspect/route.ts +++ b/apps/sim/app/api/tools/dynamodb/introspect/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRawDynamoDBClient, describeTable, listTables } from '@/app/api/tools/dynamodb/utils' const logger = createLogger('DynamoDBIntrospectAPI') @@ -14,7 +15,7 @@ const IntrospectSchema = z.object({ tableName: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -76,4 +77,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/put/route.ts b/apps/sim/app/api/tools/dynamodb/put/route.ts index 2572cdcd5e7..d094c630ec1 100644 --- a/apps/sim/app/api/tools/dynamodb/put/route.ts +++ b/apps/sim/app/api/tools/dynamodb/put/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, putItem } from '@/app/api/tools/dynamodb/utils' const PutSchema = z.object({ @@ -13,7 +14,7 @@ const PutSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -45,4 +46,4 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'DynamoDB put failed' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/query/route.ts b/apps/sim/app/api/tools/dynamodb/query/route.ts index 3b1fadeee12..6d9d75f020e 100644 --- a/apps/sim/app/api/tools/dynamodb/query/route.ts +++ b/apps/sim/app/api/tools/dynamodb/query/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, queryItems } from '@/app/api/tools/dynamodb/utils' const QuerySchema = z.object({ @@ -16,7 +17,7 @@ const QuerySchema = z.object({ limit: z.number().positive().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -60,4 +61,4 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'DynamoDB query failed' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/scan/route.ts b/apps/sim/app/api/tools/dynamodb/scan/route.ts index 64c47895b0a..8033d1e84ba 100644 --- a/apps/sim/app/api/tools/dynamodb/scan/route.ts +++ b/apps/sim/app/api/tools/dynamodb/scan/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, scanItems } from '@/app/api/tools/dynamodb/utils' const ScanSchema = z.object({ @@ -15,7 +16,7 @@ const ScanSchema = z.object({ limit: z.number().positive().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -54,4 +55,4 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'DynamoDB scan failed' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/dynamodb/update/route.ts b/apps/sim/app/api/tools/dynamodb/update/route.ts index 3a5892fe61a..c84b7d3f86e 100644 --- a/apps/sim/app/api/tools/dynamodb/update/route.ts +++ b/apps/sim/app/api/tools/dynamodb/update/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDynamoDBClient, updateItem } from '@/app/api/tools/dynamodb/utils' const UpdateSchema = z.object({ @@ -17,7 +18,7 @@ const UpdateSchema = z.object({ conditionExpression: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'DynamoDB update failed' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/copy-note/route.ts b/apps/sim/app/api/tools/evernote/copy-note/route.ts index 1011072a750..c0d588962cf 100644 --- a/apps/sim/app/api/tools/evernote/copy-note/route.ts +++ b/apps/sim/app/api/tools/evernote/copy-note/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { copyNote } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteCopyNoteAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -35,4 +36,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to copy note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/create-note/route.ts b/apps/sim/app/api/tools/evernote/create-note/route.ts index ef1c97f5982..74613be061c 100644 --- a/apps/sim/app/api/tools/evernote/create-note/route.ts +++ b/apps/sim/app/api/tools/evernote/create-note/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNote } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteCreateNoteAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -48,4 +49,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to create note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/create-notebook/route.ts b/apps/sim/app/api/tools/evernote/create-notebook/route.ts index 37ab2522d86..988ad39f68d 100644 --- a/apps/sim/app/api/tools/evernote/create-notebook/route.ts +++ b/apps/sim/app/api/tools/evernote/create-notebook/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNotebook } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteCreateNotebookAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -35,4 +36,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to create notebook', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/create-tag/route.ts b/apps/sim/app/api/tools/evernote/create-tag/route.ts index 188516cbe87..c70cd531fae 100644 --- a/apps/sim/app/api/tools/evernote/create-tag/route.ts +++ b/apps/sim/app/api/tools/evernote/create-tag/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTag } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteCreateTagAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -35,4 +36,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to create tag', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/delete-note/route.ts b/apps/sim/app/api/tools/evernote/delete-note/route.ts index e55b298496a..36d4e0d981c 100644 --- a/apps/sim/app/api/tools/evernote/delete-note/route.ts +++ b/apps/sim/app/api/tools/evernote/delete-note/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteNote } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteDeleteNoteAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -38,4 +39,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to delete note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/get-note/route.ts b/apps/sim/app/api/tools/evernote/get-note/route.ts index f71c84aa7d5..837152b3c63 100644 --- a/apps/sim/app/api/tools/evernote/get-note/route.ts +++ b/apps/sim/app/api/tools/evernote/get-note/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getNote } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteGetNoteAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -35,4 +36,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to get note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/get-notebook/route.ts b/apps/sim/app/api/tools/evernote/get-notebook/route.ts index 2f0e6db5d5d..637e88e58ce 100644 --- a/apps/sim/app/api/tools/evernote/get-notebook/route.ts +++ b/apps/sim/app/api/tools/evernote/get-notebook/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getNotebook } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteGetNotebookAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -35,4 +36,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to get notebook', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/list-notebooks/route.ts b/apps/sim/app/api/tools/evernote/list-notebooks/route.ts index be5e3df9c5f..41b2a5d56f4 100644 --- a/apps/sim/app/api/tools/evernote/list-notebooks/route.ts +++ b/apps/sim/app/api/tools/evernote/list-notebooks/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listNotebooks } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteListNotebooksAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -32,4 +33,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to list notebooks', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/list-tags/route.ts b/apps/sim/app/api/tools/evernote/list-tags/route.ts index 2475d64ee49..568b92ca922 100644 --- a/apps/sim/app/api/tools/evernote/list-tags/route.ts +++ b/apps/sim/app/api/tools/evernote/list-tags/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listTags } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteListTagsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -32,4 +33,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to list tags', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/search-notes/route.ts b/apps/sim/app/api/tools/evernote/search-notes/route.ts index 2687779e593..9e451b800cc 100644 --- a/apps/sim/app/api/tools/evernote/search-notes/route.ts +++ b/apps/sim/app/api/tools/evernote/search-notes/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { searchNotes } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteSearchNotesAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -46,4 +47,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to search notes', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/evernote/update-note/route.ts b/apps/sim/app/api/tools/evernote/update-note/route.ts index 4a3fb884504..258917f73bf 100644 --- a/apps/sim/app/api/tools/evernote/update-note/route.ts +++ b/apps/sim/app/api/tools/evernote/update-note/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { updateNote } from '@/app/api/tools/evernote/lib/client' export const dynamic = 'force-dynamic' const logger = createLogger('EvernoteUpdateNoteAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) @@ -55,4 +56,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to update note', { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/extend/parse/route.ts b/apps/sim/app/api/tools/extend/parse/route.ts index 3f604c48109..c7f2a4da888 100644 --- a/apps/sim/app/api/tools/extend/parse/route.ts +++ b/apps/sim/app/api/tools/extend/parse/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' @@ -24,7 +25,7 @@ const ExtendParseSchema = z.object({ engine: z.enum(['parse_performance', 'parse_light']).optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -185,4 +186,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 9b57cf5c379..07519a30abd 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { downloadWorkspaceFile, getWorkspaceFileByName, @@ -15,7 +16,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request, { requireWorkflowId: false }) if (!auth.success) { return NextResponse.json({ success: false, error: auth.error }, { status: 401 }) @@ -163,4 +164,4 @@ export async function POST(request: NextRequest) { logger.error('File operation failed', { operation, error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/github/latest-commit/route.ts b/apps/sim/app/api/tools/github/latest-commit/route.ts index 39d088dbecf..c06eb03b712 100644 --- a/apps/sim/app/api/tools/github/latest-commit/route.ts +++ b/apps/sim/app/api/tools/github/latest-commit/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -46,7 +47,7 @@ const GitHubLatestCommitSchema = z.object({ apiKey: z.string().min(1, 'API key is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -192,4 +193,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/add-label/route.ts b/apps/sim/app/api/tools/gmail/add-label/route.ts index 9ad66f9b4cd..c8eb5e4eaf6 100644 --- a/apps/sim/app/api/tools/gmail/add-label/route.ts +++ b/apps/sim/app/api/tools/gmail/add-label/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ const GmailAddLabelSchema = z.object({ labelIds: z.string().min(1, 'At least one label ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -138,4 +139,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/archive/route.ts b/apps/sim/app/api/tools/gmail/archive/route.ts index 784e4020116..605209ebd44 100644 --- a/apps/sim/app/api/tools/gmail/archive/route.ts +++ b/apps/sim/app/api/tools/gmail/archive/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const GmailArchiveSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -107,4 +108,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/delete/route.ts b/apps/sim/app/api/tools/gmail/delete/route.ts index a1984904654..720faab7e80 100644 --- a/apps/sim/app/api/tools/gmail/delete/route.ts +++ b/apps/sim/app/api/tools/gmail/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const GmailDeleteSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -104,4 +105,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/draft/route.ts b/apps/sim/app/api/tools/gmail/draft/route.ts index 7a6c6cf0c19..3186d7c1ee9 100644 --- a/apps/sim/app/api/tools/gmail/draft/route.ts +++ b/apps/sim/app/api/tools/gmail/draft/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -32,7 +33,7 @@ const GmailDraftSchema = z.object({ attachments: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -219,4 +220,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts index d7829bdaad9..3524f76420e 100644 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ b/apps/sim/app/api/tools/gmail/label/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { getServiceAccountToken, @@ -18,7 +19,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GmailLabelAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -151,4 +152,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Gmail label:`, error) return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts index a34df7d2679..073c0226f2b 100644 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ b/apps/sim/app/api/tools/gmail/labels/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { getServiceAccountToken, @@ -25,7 +26,7 @@ interface GmailLabel { messagesUnread?: number } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -163,4 +164,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Gmail labels:`, error) return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/mark-read/route.ts b/apps/sim/app/api/tools/gmail/mark-read/route.ts index c5b03e1c919..7fe2b909be5 100644 --- a/apps/sim/app/api/tools/gmail/mark-read/route.ts +++ b/apps/sim/app/api/tools/gmail/mark-read/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const GmailMarkReadSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -107,4 +108,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/mark-unread/route.ts b/apps/sim/app/api/tools/gmail/mark-unread/route.ts index be3fc34896a..8f194eb7a7d 100644 --- a/apps/sim/app/api/tools/gmail/mark-unread/route.ts +++ b/apps/sim/app/api/tools/gmail/mark-unread/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const GmailMarkUnreadSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -110,4 +111,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/move/route.ts b/apps/sim/app/api/tools/gmail/move/route.ts index d597c36070a..6b599f8edc0 100644 --- a/apps/sim/app/api/tools/gmail/move/route.ts +++ b/apps/sim/app/api/tools/gmail/move/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ const GmailMoveSchema = z.object({ removeLabelIds: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -131,4 +132,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/remove-label/route.ts b/apps/sim/app/api/tools/gmail/remove-label/route.ts index 4cac4e5b034..2a57fb5f9e8 100644 --- a/apps/sim/app/api/tools/gmail/remove-label/route.ts +++ b/apps/sim/app/api/tools/gmail/remove-label/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ const GmailRemoveLabelSchema = z.object({ labelIds: z.string().min(1, 'At least one label ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -141,4 +142,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/send/route.ts b/apps/sim/app/api/tools/gmail/send/route.ts index 26c0ce3f7ac..ee1f7767021 100644 --- a/apps/sim/app/api/tools/gmail/send/route.ts +++ b/apps/sim/app/api/tools/gmail/send/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -32,7 +33,7 @@ const GmailSendSchema = z.object({ attachments: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -214,4 +215,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/gmail/unarchive/route.ts b/apps/sim/app/api/tools/gmail/unarchive/route.ts index 84be1f5ee3a..f6f774e1574 100644 --- a/apps/sim/app/api/tools/gmail/unarchive/route.ts +++ b/apps/sim/app/api/tools/gmail/unarchive/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const GmailUnarchiveSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -107,4 +108,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts index 5f6ba8c10ce..56b69f90874 100644 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -17,7 +18,7 @@ export const dynamic = 'force-dynamic' * @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body * @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName` */ -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -103,4 +104,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts index 2489d87821d..c4754591ddf 100644 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ b/apps/sim/app/api/tools/google_bigquery/tables/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -9,7 +10,7 @@ const logger = createLogger('GoogleBigQueryTablesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -97,4 +98,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index f0f38b63251..c551eca55e8 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -21,7 +22,7 @@ interface CalendarListItem { /** * Get calendars from Google Calendar */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Google Calendar calendars request received`) @@ -109,4 +110,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Google calendars`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/google_drive/download/route.ts b/apps/sim/app/api/tools/google_drive/download/route.ts index e4131423f91..e0022b84682 100644 --- a/apps/sim/app/api/tools/google_drive/download/route.ts +++ b/apps/sim/app/api/tools/google_drive/download/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { GoogleDriveFile, GoogleDriveRevision } from '@/tools/google_drive/types' import { ALL_FILE_FIELDS, @@ -42,7 +43,7 @@ const GoogleDriveDownloadSchema = z.object({ includeRevisions: z.boolean().optional().default(true), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -255,4 +256,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index 3549245fd53..d5b0321f20f 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -53,7 +54,7 @@ function buildMultipartBody( return parts.join('\r\n') } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -297,4 +298,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/google_sheets/sheets/route.ts b/apps/sim/app/api/tools/google_sheets/sheets/route.ts index d5aae20e3e0..22bc17cfb61 100644 --- a/apps/sim/app/api/tools/google_sheets/sheets/route.ts +++ b/apps/sim/app/api/tools/google_sheets/sheets/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -27,7 +28,7 @@ interface SpreadsheetResponse { /** * Get sheets (tabs) from a Google Spreadsheet */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Google Sheets sheets request received`) @@ -125,4 +126,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts index a4b831c9f3a..7d9f0938872 100644 --- a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts +++ b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' import { refreshAccessTokenIfNeeded, ServiceAccountTokenError } from '@/app/api/auth/oauth/utils' @@ -9,7 +10,7 @@ const logger = createLogger('GoogleTasksTaskListsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -82,4 +83,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts index 01bdfd3f50b..7befe05ee5c 100644 --- a/apps/sim/app/api/tools/google_vault/download-export-file/route.ts +++ b/apps/sim/app/api/tools/google_vault/download-export-file/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enhanceGoogleVaultError } from '@/tools/google_vault/utils' export const dynamic = 'force-dynamic' @@ -20,7 +21,7 @@ const GoogleVaultDownloadExportFileSchema = z.object({ fileName: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -128,4 +129,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index 475a9de5c63..64243852cd3 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -6,6 +6,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ImageProxyAPI') @@ -13,7 +14,7 @@ const logger = createLogger('ImageProxyAPI') * Proxy for fetching images * This allows client-side requests to fetch images from various sources while avoiding CORS issues */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const url = new URL(request.url) const imageUrl = url.searchParams.get('url') const requestId = generateRequestId() @@ -90,9 +91,9 @@ export async function GET(request: NextRequest) { status: 500, }) } -} +}) -export async function OPTIONS() { +export const OPTIONS = withRouteHandler(async () => { return new NextResponse(null, { status: 204, headers: { @@ -102,4 +103,4 @@ export async function OPTIONS() { 'Access-Control-Max-Age': '86400', }, }) -} +}) diff --git a/apps/sim/app/api/tools/imap/mailboxes/route.ts b/apps/sim/app/api/tools/imap/mailboxes/route.ts index e2f3056aa29..02a8b787a77 100644 --- a/apps/sim/app/api/tools/imap/mailboxes/route.ts +++ b/apps/sim/app/api/tools/imap/mailboxes/route.ts @@ -3,6 +3,7 @@ import { ImapFlow } from 'imapflow' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ImapMailboxesAPI') @@ -14,7 +15,7 @@ interface ImapMailboxRequest { password: string } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 }) @@ -95,4 +96,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, message: userMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/jira/add-attachment/route.ts b/apps/sim/app/api/tools/jira/add-attachment/route.ts index 110a915703b..f2b528bf3c2 100644 --- a/apps/sim/app/api/tools/jira/add-attachment/route.ts +++ b/apps/sim/app/api/tools/jira/add-attachment/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -19,7 +20,7 @@ const JiraAddAttachmentSchema = z.object({ cloudId: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = `jira-attach-${Date.now()}` try { @@ -119,4 +120,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts index dfebb1d9e16..2b13e7416df 100644 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ b/apps/sim/app/api/tools/jira/issues/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' @@ -27,7 +28,7 @@ const validateRequiredParams = (domain: string | null, accessToken: string | nul return null } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -105,9 +106,9 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -240,4 +241,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jira/projects/route.ts b/apps/sim/app/api/tools/jira/projects/route.ts index 994320cb463..a6ab73f7b01 100644 --- a/apps/sim/app/api/tools/jira/projects/route.ts +++ b/apps/sim/app/api/tools/jira/projects/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JiraProjectsAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -102,9 +103,9 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -180,4 +181,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jira/update/route.ts b/apps/sim/app/api/tools/jira/update/route.ts index c77dceb41b1..ca816c68d17 100644 --- a/apps/sim/app/api/tools/jira/update/route.ts +++ b/apps/sim/app/api/tools/jira/update/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' @@ -29,7 +30,7 @@ const jiraUpdateSchema = z.object({ cloudId: z.string().optional(), }) -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -219,4 +220,4 @@ export async function PUT(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jira/write/route.ts b/apps/sim/app/api/tools/jira/write/route.ts index cf3168e751e..5b1250d3d5b 100644 --- a/apps/sim/app/api/tools/jira/write/route.ts +++ b/apps/sim/app/api/tools/jira/write/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JiraWriteAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) if (!auth.success || !auth.userId) { @@ -262,4 +263,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/approvals/route.ts b/apps/sim/app/api/tools/jsm/approvals/route.ts index e579121e8b5..ff7179adaba 100644 --- a/apps/sim/app/api/tools/jsm/approvals/route.ts +++ b/apps/sim/app/api/tools/jsm/approvals/route.ts @@ -7,6 +7,7 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -16,7 +17,7 @@ const logger = createLogger('JsmApprovalsAPI') const VALID_ACTIONS = ['get', 'answer'] as const const VALID_DECISIONS = ['approve', 'decline'] as const -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -204,4 +205,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/comment/route.ts b/apps/sim/app/api/tools/jsm/comment/route.ts index 946a17bb20b..387cfbcd4df 100644 --- a/apps/sim/app/api/tools/jsm/comment/route.ts +++ b/apps/sim/app/api/tools/jsm/comment/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmCommentAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -120,4 +121,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/comments/route.ts b/apps/sim/app/api/tools/jsm/comments/route.ts index d68c51b8bd3..b4b0aba0d41 100644 --- a/apps/sim/app/api/tools/jsm/comments/route.ts +++ b/apps/sim/app/api/tools/jsm/comments/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmCommentsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -113,4 +114,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/customers/route.ts b/apps/sim/app/api/tools/jsm/customers/route.ts index cf9fcf7e63d..a0353626262 100644 --- a/apps/sim/app/api/tools/jsm/customers/route.ts +++ b/apps/sim/app/api/tools/jsm/customers/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmCustomersAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -162,4 +163,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/organization/route.ts b/apps/sim/app/api/tools/jsm/organization/route.ts index c0fc932035f..9532b94b96f 100644 --- a/apps/sim/app/api/tools/jsm/organization/route.ts +++ b/apps/sim/app/api/tools/jsm/organization/route.ts @@ -6,6 +6,7 @@ import { validateEnum, validateJiraCloudId, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const logger = createLogger('JsmOrganizationAPI') const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -174,4 +175,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/organizations/route.ts b/apps/sim/app/api/tools/jsm/organizations/route.ts index 79971e27dac..21a1f4fc69a 100644 --- a/apps/sim/app/api/tools/jsm/organizations/route.ts +++ b/apps/sim/app/api/tools/jsm/organizations/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmOrganizationsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -99,4 +100,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/participants/route.ts b/apps/sim/app/api/tools/jsm/participants/route.ts index 93a0293d136..e4aed39a944 100644 --- a/apps/sim/app/api/tools/jsm/participants/route.ts +++ b/apps/sim/app/api/tools/jsm/participants/route.ts @@ -6,6 +6,7 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const logger = createLogger('JsmParticipantsAPI') const VALID_ACTIONS = ['get', 'add'] as const -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -179,4 +180,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/queues/route.ts b/apps/sim/app/api/tools/jsm/queues/route.ts index 2921008efcb..b363250849d 100644 --- a/apps/sim/app/api/tools/jsm/queues/route.ts +++ b/apps/sim/app/api/tools/jsm/queues/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmQueuesAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -108,4 +109,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/request/route.ts b/apps/sim/app/api/tools/jsm/request/route.ts index ae5b150b5b8..c6f52871c74 100644 --- a/apps/sim/app/api/tools/jsm/request/route.ts +++ b/apps/sim/app/api/tools/jsm/request/route.ts @@ -6,13 +6,14 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmRequestAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -232,4 +233,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/requests/route.ts b/apps/sim/app/api/tools/jsm/requests/route.ts index 70a4cc8ce94..33fc8956061 100644 --- a/apps/sim/app/api/tools/jsm/requests/route.ts +++ b/apps/sim/app/api/tools/jsm/requests/route.ts @@ -6,13 +6,14 @@ import { validateEnum, validateJiraCloudId, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmRequestsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -148,4 +149,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/requesttypefields/route.ts b/apps/sim/app/api/tools/jsm/requesttypefields/route.ts index 5e86337aea6..f7d52972f73 100644 --- a/apps/sim/app/api/tools/jsm/requesttypefields/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypefields/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmRequestTypeFieldsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -116,4 +117,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/requesttypes/route.ts b/apps/sim/app/api/tools/jsm/requesttypes/route.ts index 9426fe8479a..1710508d64e 100644 --- a/apps/sim/app/api/tools/jsm/requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypes/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmRequestTypesAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -112,4 +113,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts index a9ef02bec86..c4fa308d817 100644 --- a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ const logger = createLogger('JsmSelectorRequestTypesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -100,4 +101,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts index b4bc93032fb..5229b67b6e7 100644 --- a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ const logger = createLogger('JsmSelectorServiceDesksAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -91,4 +92,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/servicedesks/route.ts b/apps/sim/app/api/tools/jsm/servicedesks/route.ts index e6721be528d..75933178cd8 100644 --- a/apps/sim/app/api/tools/jsm/servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/servicedesks/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmServiceDesksAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -90,4 +91,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/sla/route.ts b/apps/sim/app/api/tools/jsm/sla/route.ts index dc414ac8310..0695ed14c9f 100644 --- a/apps/sim/app/api/tools/jsm/sla/route.ts +++ b/apps/sim/app/api/tools/jsm/sla/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmSlaAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -100,4 +101,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/transition/route.ts b/apps/sim/app/api/tools/jsm/transition/route.ts index 45a9e3a5c26..d9942da658c 100644 --- a/apps/sim/app/api/tools/jsm/transition/route.ts +++ b/apps/sim/app/api/tools/jsm/transition/route.ts @@ -6,13 +6,14 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmTransitionAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -124,4 +125,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/transitions/route.ts b/apps/sim/app/api/tools/jsm/transitions/route.ts index d1001452f99..fc6fd617b7d 100644 --- a/apps/sim/app/api/tools/jsm/transitions/route.ts +++ b/apps/sim/app/api/tools/jsm/transitions/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' export const dynamic = 'force-dynamic' const logger = createLogger('JsmTransitionsAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -100,4 +101,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index 9e0ff733543..b0370fd3793 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -4,13 +4,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('LinearProjectsAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const body = await request.json() const { credential, teamId, workflowId } = body @@ -70,4 +71,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts index ee82c154256..05ac1132c6d 100644 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ b/apps/sim/app/api/tools/linear/teams/route.ts @@ -4,13 +4,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('LinearTeamsAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -63,4 +64,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/mail/send/route.ts b/apps/sim/app/api/tools/mail/send/route.ts index b75f5c06ebc..e2413607a24 100644 --- a/apps/sim/app/api/tools/mail/send/route.ts +++ b/apps/sim/app/api/tools/mail/send/route.ts @@ -4,6 +4,7 @@ import { Resend } from 'resend' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -32,7 +33,7 @@ const MailSendSchema = z.object({ tags: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -160,4 +161,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts index 003daa064a5..bc67c5921e0 100644 --- a/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts +++ b/apps/sim/app/api/tools/microsoft-dataverse/upload-file/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -22,7 +23,7 @@ const DataverseUploadFileSchema = z.object({ fileContent: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -142,4 +143,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts index 0dc1fa8a0a4..89a264f8f71 100644 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('TeamsChannelsAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const body = await request.json() @@ -128,4 +129,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts index a0113647a61..73126c99c2c 100644 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -123,7 +124,7 @@ const getChatDisplayName = async ( } } -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const body = await request.json() @@ -229,4 +230,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts index a903815abe2..16387969b11 100644 --- a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('TeamsTeamsAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const body = await request.json() @@ -118,4 +119,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts index d4f8035149e..dd50c69bbdf 100644 --- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -22,7 +23,7 @@ interface WorksheetsResponse { /** * Get worksheets (tabs) from a Microsoft Excel workbook */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Microsoft Excel sheets request received`) @@ -109,4 +110,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Microsoft Excel worksheets`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts index e43650d3d7a..a298ef1dc9a 100644 --- a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('MicrosoftPlannerPlansAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { @@ -69,4 +70,4 @@ export async function POST(request: Request) { logger.error(`[${requestId}] Error fetching Microsoft Planner plans:`, error) return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts index db0ccd88ae6..430e291407c 100644 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { PlannerTask } from '@/tools/microsoft_planner/types' @@ -10,7 +11,7 @@ const logger = createLogger('MicrosoftPlannerTasksAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { @@ -100,4 +101,4 @@ export async function POST(request: Request) { logger.error(`[${requestId}] Error fetching Microsoft Planner tasks:`, error) return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts b/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts index 549cde3f8b2..aec29f546de 100644 --- a/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/delete_chat_message/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const TeamsDeleteChatMessageSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -118,4 +119,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index a477f68d8b4..7fd864e24cc 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' @@ -21,7 +22,7 @@ const TeamsWriteChannelSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -175,4 +176,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index 67df1e4028a..1f4399c8932 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { uploadFilesForTeamsMessage } from '@/tools/microsoft_teams/server-utils' import type { GraphApiErrorResponse, GraphChatMessage } from '@/tools/microsoft_teams/types' @@ -20,7 +21,7 @@ const TeamsWriteChatSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -171,4 +172,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index f8b0c11915a..984d74963ad 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { @@ -30,7 +31,7 @@ const MistralParseSchema = z.object({ imageMinSize: z.number().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -274,4 +275,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/mongodb/delete/route.ts b/apps/sim/app/api/tools/mongodb/delete/route.ts index 5325aa57446..f70f838e57d 100644 --- a/apps/sim/app/api/tools/mongodb/delete/route.ts +++ b/apps/sim/app/api/tools/mongodb/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' const logger = createLogger('MongoDBDeleteAPI') @@ -37,7 +38,7 @@ const DeleteSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -118,4 +119,4 @@ export async function POST(request: NextRequest) { await client.close() } } -} +}) diff --git a/apps/sim/app/api/tools/mongodb/execute/route.ts b/apps/sim/app/api/tools/mongodb/execute/route.ts index 9ae06a07871..8f8a00f577c 100644 --- a/apps/sim/app/api/tools/mongodb/execute/route.ts +++ b/apps/sim/app/api/tools/mongodb/execute/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils' const logger = createLogger('MongoDBExecuteAPI') @@ -29,7 +30,7 @@ const ExecuteSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -106,4 +107,4 @@ export async function POST(request: NextRequest) { await client.close() } } -} +}) diff --git a/apps/sim/app/api/tools/mongodb/insert/route.ts b/apps/sim/app/api/tools/mongodb/insert/route.ts index 94957ae5630..7d0f664dd1c 100644 --- a/apps/sim/app/api/tools/mongodb/insert/route.ts +++ b/apps/sim/app/api/tools/mongodb/insert/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMongoDBConnection, sanitizeCollectionName } from '../utils' const logger = createLogger('MongoDBInsertAPI') @@ -34,7 +35,7 @@ const InsertSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -102,4 +103,4 @@ export async function POST(request: NextRequest) { await client.close() } } -} +}) diff --git a/apps/sim/app/api/tools/mongodb/introspect/route.ts b/apps/sim/app/api/tools/mongodb/introspect/route.ts index d6c4b6e5f7f..0768f6e9eb1 100644 --- a/apps/sim/app/api/tools/mongodb/introspect/route.ts +++ b/apps/sim/app/api/tools/mongodb/introspect/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMongoDBConnection, executeIntrospect } from '../utils' const logger = createLogger('MongoDBIntrospectAPI') @@ -17,7 +18,7 @@ const IntrospectSchema = z.object({ ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -77,4 +78,4 @@ export async function POST(request: NextRequest) { await client.close() } } -} +}) diff --git a/apps/sim/app/api/tools/mongodb/query/route.ts b/apps/sim/app/api/tools/mongodb/query/route.ts index 24829c660b1..bd5920b90c8 100644 --- a/apps/sim/app/api/tools/mongodb/query/route.ts +++ b/apps/sim/app/api/tools/mongodb/query/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' const logger = createLogger('MongoDBQueryAPI') @@ -46,7 +47,7 @@ const QuerySchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -140,4 +141,4 @@ export async function POST(request: NextRequest) { await client.close() } } -} +}) diff --git a/apps/sim/app/api/tools/mongodb/update/route.ts b/apps/sim/app/api/tools/mongodb/update/route.ts index 47022203ee7..2c12e5edeea 100644 --- a/apps/sim/app/api/tools/mongodb/update/route.ts +++ b/apps/sim/app/api/tools/mongodb/update/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' const logger = createLogger('MongoDBUpdateAPI') @@ -56,7 +57,7 @@ const UpdateSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let client = null @@ -147,4 +148,4 @@ export async function POST(request: NextRequest) { await client.close() } } -} +}) diff --git a/apps/sim/app/api/tools/mysql/delete/route.ts b/apps/sim/app/api/tools/mysql/delete/route.ts index b1871cff38a..4b0c41e04fa 100644 --- a/apps/sim/app/api/tools/mysql/delete/route.ts +++ b/apps/sim/app/api/tools/mysql/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLDeleteAPI') @@ -18,7 +19,7 @@ const DeleteSchema = z.object({ where: z.string().min(1, 'WHERE clause is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -72,4 +73,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `MySQL delete failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/mysql/execute/route.ts b/apps/sim/app/api/tools/mysql/execute/route.ts index 1e8e1685ba3..f0a30989daf 100644 --- a/apps/sim/app/api/tools/mysql/execute/route.ts +++ b/apps/sim/app/api/tools/mysql/execute/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLExecuteAPI') @@ -17,7 +18,7 @@ const ExecuteSchema = z.object({ query: z.string().min(1, 'Query is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -79,4 +80,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `MySQL execute failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/mysql/insert/route.ts b/apps/sim/app/api/tools/mysql/insert/route.ts index 862a3332c25..6e3b7999fff 100644 --- a/apps/sim/app/api/tools/mysql/insert/route.ts +++ b/apps/sim/app/api/tools/mysql/insert/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLInsertAPI') @@ -39,7 +40,7 @@ const InsertSchema = z.object({ ]), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -93,4 +94,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `MySQL insert failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/mysql/introspect/route.ts b/apps/sim/app/api/tools/mysql/introspect/route.ts index 792b012d126..37e4f5a1c4d 100644 --- a/apps/sim/app/api/tools/mysql/introspect/route.ts +++ b/apps/sim/app/api/tools/mysql/introspect/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLIntrospectAPI') @@ -16,7 +17,7 @@ const IntrospectSchema = z.object({ ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -74,4 +75,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/mysql/query/route.ts b/apps/sim/app/api/tools/mysql/query/route.ts index 5e1105053fc..5c38a8675da 100644 --- a/apps/sim/app/api/tools/mysql/query/route.ts +++ b/apps/sim/app/api/tools/mysql/query/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLQueryAPI') @@ -17,7 +18,7 @@ const QuerySchema = z.object({ query: z.string().min(1, 'Query is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -79,4 +80,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `MySQL query failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/mysql/update/route.ts b/apps/sim/app/api/tools/mysql/update/route.ts index 0eff371d83c..77646b617cf 100644 --- a/apps/sim/app/api/tools/mysql/update/route.ts +++ b/apps/sim/app/api/tools/mysql/update/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils' const logger = createLogger('MySQLUpdateAPI') @@ -37,7 +38,7 @@ const UpdateSchema = z.object({ where: z.string().min(1, 'WHERE clause is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -91,4 +92,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `MySQL update failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/create/route.ts b/apps/sim/app/api/tools/neo4j/create/route.ts index 83c1b16c6d4..4b432849032 100644 --- a/apps/sim/app/api/tools/neo4j/create/route.ts +++ b/apps/sim/app/api/tools/neo4j/create/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { convertNeo4jTypesToJSON, createNeo4jDriver, @@ -22,7 +23,7 @@ const CreateSchema = z.object({ parameters: z.record(z.unknown()).nullable().optional().default({}), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -122,4 +123,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/delete/route.ts b/apps/sim/app/api/tools/neo4j/delete/route.ts index 5b4c42d7da2..75ad1d6ae58 100644 --- a/apps/sim/app/api/tools/neo4j/delete/route.ts +++ b/apps/sim/app/api/tools/neo4j/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils' const logger = createLogger('Neo4jDeleteAPI') @@ -19,7 +20,7 @@ const DeleteSchema = z.object({ detach: z.boolean().optional().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -107,4 +108,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/execute/route.ts b/apps/sim/app/api/tools/neo4j/execute/route.ts index 70eb498b5b2..4ba262b7121 100644 --- a/apps/sim/app/api/tools/neo4j/execute/route.ts +++ b/apps/sim/app/api/tools/neo4j/execute/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { convertNeo4jTypesToJSON, createNeo4jDriver, @@ -22,7 +23,7 @@ const ExecuteSchema = z.object({ parameters: z.record(z.unknown()).nullable().optional().default({}), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -120,4 +121,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/introspect/route.ts b/apps/sim/app/api/tools/neo4j/introspect/route.ts index 36604473fb6..b76f8a01589 100644 --- a/apps/sim/app/api/tools/neo4j/introspect/route.ts +++ b/apps/sim/app/api/tools/neo4j/introspect/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createNeo4jDriver } from '@/app/api/tools/neo4j/utils' import type { Neo4jNodeSchema, Neo4jRelationshipSchema } from '@/tools/neo4j/types' @@ -17,7 +18,7 @@ const IntrospectSchema = z.object({ encryption: z.enum(['enabled', 'disabled']).default('disabled'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -203,4 +204,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/merge/route.ts b/apps/sim/app/api/tools/neo4j/merge/route.ts index e4865aeabf4..5c392c9483c 100644 --- a/apps/sim/app/api/tools/neo4j/merge/route.ts +++ b/apps/sim/app/api/tools/neo4j/merge/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { convertNeo4jTypesToJSON, createNeo4jDriver, @@ -22,7 +23,7 @@ const MergeSchema = z.object({ parameters: z.record(z.unknown()).nullable().optional().default({}), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -122,4 +123,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/query/route.ts b/apps/sim/app/api/tools/neo4j/query/route.ts index 7c6d8983675..8d9773c9e7b 100644 --- a/apps/sim/app/api/tools/neo4j/query/route.ts +++ b/apps/sim/app/api/tools/neo4j/query/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { convertNeo4jTypesToJSON, createNeo4jDriver, @@ -22,7 +23,7 @@ const QuerySchema = z.object({ parameters: z.record(z.unknown()).nullable().optional().default({}), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -120,4 +121,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/neo4j/update/route.ts b/apps/sim/app/api/tools/neo4j/update/route.ts index 5d90e17a568..729ca797d96 100644 --- a/apps/sim/app/api/tools/neo4j/update/route.ts +++ b/apps/sim/app/api/tools/neo4j/update/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { convertNeo4jTypesToJSON, createNeo4jDriver, @@ -22,7 +23,7 @@ const UpdateSchema = z.object({ parameters: z.record(z.unknown()).nullable().optional().default({}), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) let driver = null let session = null @@ -122,4 +123,4 @@ export async function POST(request: NextRequest) { await driver.close() } } -} +}) diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts index 1dee214a2d9..2448f067d98 100644 --- a/apps/sim/app/api/tools/notion/databases/route.ts +++ b/apps/sim/app/api/tools/notion/databases/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { extractTitleFromItem } from '@/tools/notion/utils' @@ -9,7 +10,7 @@ const logger = createLogger('NotionDatabasesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -83,4 +84,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts index 0a0bd4f4703..3c01c36b834 100644 --- a/apps/sim/app/api/tools/notion/pages/route.ts +++ b/apps/sim/app/api/tools/notion/pages/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { extractTitleFromItem } from '@/tools/notion/utils' @@ -9,7 +10,7 @@ const logger = createLogger('NotionPagesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -83,4 +84,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/onedrive/download/route.ts b/apps/sim/app/api/tools/onedrive/download/route.ts index 2cc268ffd5e..8afe7b7ff8d 100644 --- a/apps/sim/app/api/tools/onedrive/download/route.ts +++ b/apps/sim/app/api/tools/onedrive/download/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -36,7 +37,7 @@ const OneDriveDownloadSchema = z.object({ fileName: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -180,4 +181,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts index 16baea2e23d..5b9184465db 100644 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ b/apps/sim/app/api/tools/onedrive/files/route.ts @@ -12,12 +12,13 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFilesAPI') +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' /** * Get files (not folders) from Microsoft OneDrive */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) logger.info(`[${requestId}] OneDrive files request received`) @@ -187,4 +188,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching files from OneDrive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts index 2ab90355b7c..40b575153e8 100644 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ b/apps/sim/app/api/tools/onedrive/folder/route.ts @@ -6,13 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFolderAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -104,4 +105,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching folder from OneDrive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts index 5cf6981f801..cb5699ce32f 100644 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ b/apps/sim/app/api/tools/onedrive/folders/route.ts @@ -12,12 +12,13 @@ export const dynamic = 'force-dynamic' const logger = createLogger('OneDriveFoldersAPI') +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' /** * Get folders from Microsoft OneDrive */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -114,4 +115,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching folders from OneDrive`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onedrive/upload/route.ts b/apps/sim/app/api/tools/onedrive/upload/route.ts index 8919b528cd4..1af24e81e0e 100644 --- a/apps/sim/app/api/tools/onedrive/upload/route.ts +++ b/apps/sim/app/api/tools/onedrive/upload/route.ts @@ -6,6 +6,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getExtensionFromMimeType, @@ -58,7 +59,7 @@ interface ExcelRangeData { values?: unknown[][] } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -437,4 +438,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/create-item/route.ts b/apps/sim/app/api/tools/onepassword/create-item/route.ts index 497e71b1991..2c48f914f9a 100644 --- a/apps/sim/app/api/tools/onepassword/create-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/create-item/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -27,7 +28,7 @@ const CreateItemSchema = z.object({ fields: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -110,4 +111,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Create item failed:`, error) return NextResponse.json({ error: `Failed to create item: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/delete-item/route.ts b/apps/sim/app/api/tools/onepassword/delete-item/route.ts index c2be6e8f1eb..6a3865d7b2a 100644 --- a/apps/sim/app/api/tools/onepassword/delete-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/delete-item/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, resolveCredentials } from '../utils' const logger = createLogger('OnePasswordDeleteItemAPI') @@ -16,7 +17,7 @@ const DeleteItemSchema = z.object({ itemId: z.string().min(1, 'Item ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -67,4 +68,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Delete item failed:`, error) return NextResponse.json({ error: `Failed to delete item: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/get-item/route.ts b/apps/sim/app/api/tools/onepassword/get-item/route.ts index 92065228e81..6d10be903ee 100644 --- a/apps/sim/app/api/tools/onepassword/get-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/get-item/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -21,7 +22,7 @@ const GetItemSchema = z.object({ itemId: z.string().min(1, 'Item ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -72,4 +73,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Get item failed:`, error) return NextResponse.json({ error: `Failed to get item: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/get-vault/route.ts b/apps/sim/app/api/tools/onepassword/get-vault/route.ts index 09b2b227fe2..63d7febf401 100644 --- a/apps/sim/app/api/tools/onepassword/get-vault/route.ts +++ b/apps/sim/app/api/tools/onepassword/get-vault/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -20,7 +21,7 @@ const GetVaultSchema = z.object({ vaultId: z.string().min(1, 'Vault ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -75,4 +76,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Get vault failed:`, error) return NextResponse.json({ error: `Failed to get vault: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/list-items/route.ts b/apps/sim/app/api/tools/onepassword/list-items/route.ts index 6f4d4c6eb9d..8b99118c4c6 100644 --- a/apps/sim/app/api/tools/onepassword/list-items/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-items/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -21,7 +22,7 @@ const ListItemsSchema = z.object({ filter: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -84,4 +85,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] List items failed:`, error) return NextResponse.json({ error: `Failed to list items: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts index e24d8567abc..6db5efbfa52 100644 --- a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -20,7 +21,7 @@ const ListVaultsSchema = z.object({ filter: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -82,4 +83,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] List vaults failed:`, error) return NextResponse.json({ error: `Failed to list vaults: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/replace-item/route.ts b/apps/sim/app/api/tools/onepassword/replace-item/route.ts index 48b84918b5f..252be437d5c 100644 --- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -25,7 +26,7 @@ const ReplaceItemSchema = z.object({ item: z.string().min(1, 'Item JSON is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -114,4 +115,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Replace item failed:`, error) return NextResponse.json({ error: `Failed to replace item: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts b/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts index e327da13d68..1ba5340cfa2 100644 --- a/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts +++ b/apps/sim/app/api/tools/onepassword/resolve-secret/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createOnePasswordClient, resolveCredentials } from '../utils' const logger = createLogger('OnePasswordResolveSecretAPI') @@ -15,7 +16,7 @@ const ResolveSecretSchema = z.object({ secretReference: z.string().min(1, 'Secret reference is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -56,4 +57,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Resolve secret failed:`, error) return NextResponse.json({ error: `Failed to resolve secret: ${message}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/onepassword/update-item/route.ts b/apps/sim/app/api/tools/onepassword/update-item/route.ts index 1bfca62a68b..6cc0f679643 100644 --- a/apps/sim/app/api/tools/onepassword/update-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/update-item/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, @@ -22,7 +23,7 @@ const UpdateItemSchema = z.object({ operations: z.string().min(1, 'Patch operations are required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -82,7 +83,7 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Update item failed:`, error) return NextResponse.json({ error: `Failed to update item: ${message}` }, { status: 500 }) } -} +}) interface JsonPatchOperation { op: 'add' | 'remove' | 'replace' diff --git a/apps/sim/app/api/tools/outlook/copy/route.ts b/apps/sim/app/api/tools/outlook/copy/route.ts index 17b40405a7a..8bb47a0b5dc 100644 --- a/apps/sim/app/api/tools/outlook/copy/route.ts +++ b/apps/sim/app/api/tools/outlook/copy/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const OutlookCopySchema = z.object({ destinationId: z.string().min(1, 'Destination folder ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -109,4 +110,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/delete/route.ts b/apps/sim/app/api/tools/outlook/delete/route.ts index 2646ad076db..f697c571280 100644 --- a/apps/sim/app/api/tools/outlook/delete/route.ts +++ b/apps/sim/app/api/tools/outlook/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -13,7 +14,7 @@ const OutlookDeleteSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -98,4 +99,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/draft/route.ts b/apps/sim/app/api/tools/outlook/draft/route.ts index 801b3b90869..f58386af86e 100644 --- a/apps/sim/app/api/tools/outlook/draft/route.ts +++ b/apps/sim/app/api/tools/outlook/draft/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -22,7 +23,7 @@ const OutlookDraftSchema = z.object({ attachments: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -191,4 +192,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts index 8bf9e906d17..6a355b4cc53 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -6,6 +6,7 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ interface OutlookFolder { unreadItemCount?: number } -export async function GET(request: Request) { +export const GET = withRouteHandler(async (request: Request) => { try { const session = await getSession() const { searchParams } = new URL(request.url) @@ -164,4 +165,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/mark-read/route.ts b/apps/sim/app/api/tools/outlook/mark-read/route.ts index f8f8305ee14..f393c9b8f5d 100644 --- a/apps/sim/app/api/tools/outlook/mark-read/route.ts +++ b/apps/sim/app/api/tools/outlook/mark-read/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -13,7 +14,7 @@ const OutlookMarkReadSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -108,4 +109,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/mark-unread/route.ts b/apps/sim/app/api/tools/outlook/mark-unread/route.ts index 797e9d979dc..1e1078402b9 100644 --- a/apps/sim/app/api/tools/outlook/mark-unread/route.ts +++ b/apps/sim/app/api/tools/outlook/mark-unread/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -13,7 +14,7 @@ const OutlookMarkUnreadSchema = z.object({ messageId: z.string().min(1, 'Message ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -108,4 +109,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/move/route.ts b/apps/sim/app/api/tools/outlook/move/route.ts index 57c11736ad9..24b04ed252e 100644 --- a/apps/sim/app/api/tools/outlook/move/route.ts +++ b/apps/sim/app/api/tools/outlook/move/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -14,7 +15,7 @@ const OutlookMoveSchema = z.object({ destinationId: z.string().min(1, 'Destination folder ID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -107,4 +108,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/outlook/send/route.ts b/apps/sim/app/api/tools/outlook/send/route.ts index f2d39ef11e6..6d8eac4cfcc 100644 --- a/apps/sim/app/api/tools/outlook/send/route.ts +++ b/apps/sim/app/api/tools/outlook/send/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -24,7 +25,7 @@ const OutlookSendSchema = z.object({ attachments: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -204,4 +205,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/pipedrive/get-files/route.ts b/apps/sim/app/api/tools/pipedrive/get-files/route.ts index fae904ffc39..60120daec7a 100644 --- a/apps/sim/app/api/tools/pipedrive/get-files/route.ts +++ b/apps/sim/app/api/tools/pipedrive/get-files/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' export const dynamic = 'force-dynamic' @@ -39,7 +40,7 @@ const PipedriveGetFilesSchema = z.object({ downloadFiles: z.boolean().optional().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -170,4 +171,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts index ba188e6c386..bfb39961b08 100644 --- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts +++ b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('PipedrivePipelinesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -76,4 +77,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/postgresql/delete/route.ts b/apps/sim/app/api/tools/postgresql/delete/route.ts index 05309cda907..dac5d54e037 100644 --- a/apps/sim/app/api/tools/postgresql/delete/route.ts +++ b/apps/sim/app/api/tools/postgresql/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLDeleteAPI') @@ -18,7 +19,7 @@ const DeleteSchema = z.object({ where: z.string().min(1, 'WHERE clause is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -74,4 +75,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/postgresql/execute/route.ts b/apps/sim/app/api/tools/postgresql/execute/route.ts index 1dba7c11414..4b159489232 100644 --- a/apps/sim/app/api/tools/postgresql/execute/route.ts +++ b/apps/sim/app/api/tools/postgresql/execute/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeQuery, @@ -21,7 +22,7 @@ const ExecuteSchema = z.object({ query: z.string().min(1, 'Query is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -86,4 +87,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/postgresql/insert/route.ts b/apps/sim/app/api/tools/postgresql/insert/route.ts index 01073a96577..bd277689b02 100644 --- a/apps/sim/app/api/tools/postgresql/insert/route.ts +++ b/apps/sim/app/api/tools/postgresql/insert/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLInsertAPI') @@ -39,7 +40,7 @@ const InsertSchema = z.object({ ]), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -96,4 +97,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/postgresql/introspect/route.ts b/apps/sim/app/api/tools/postgresql/introspect/route.ts index cf376bef9de..97fa8cb1279 100644 --- a/apps/sim/app/api/tools/postgresql/introspect/route.ts +++ b/apps/sim/app/api/tools/postgresql/introspect/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLIntrospectAPI') @@ -17,7 +18,7 @@ const IntrospectSchema = z.object({ schema: z.string().default('public'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -75,4 +76,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/postgresql/query/route.ts b/apps/sim/app/api/tools/postgresql/query/route.ts index 72e73489a5b..cb0ece363c9 100644 --- a/apps/sim/app/api/tools/postgresql/query/route.ts +++ b/apps/sim/app/api/tools/postgresql/query/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLQueryAPI') @@ -17,7 +18,7 @@ const QuerySchema = z.object({ query: z.string().min(1, 'Query is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -70,4 +71,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `PostgreSQL query failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/postgresql/update/route.ts b/apps/sim/app/api/tools/postgresql/update/route.ts index 4eb2cc9d4da..5f4c0b2c782 100644 --- a/apps/sim/app/api/tools/postgresql/update/route.ts +++ b/apps/sim/app/api/tools/postgresql/update/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils' const logger = createLogger('PostgreSQLUpdateAPI') @@ -37,7 +38,7 @@ const UpdateSchema = z.object({ where: z.string().min(1, 'WHERE clause is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -93,4 +94,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/pulse/parse/route.ts b/apps/sim/app/api/tools/pulse/parse/route.ts index 39dc9259a43..30b5a198803 100644 --- a/apps/sim/app/api/tools/pulse/parse/route.ts +++ b/apps/sim/app/api/tools/pulse/parse/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' @@ -27,7 +28,7 @@ const PulseParseSchema = z.object({ chunkSize: z.number().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -173,4 +174,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts index c65118b3773..9d1cc21ebff 100644 --- a/apps/sim/app/api/tools/quiver/image-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/image-to-svg/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -21,7 +22,7 @@ const RequestSchema = z.object({ target_size: z.number().int().min(128).max(4096).optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -138,4 +139,4 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : 'Unknown error' return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts index 9a81b440bd3..c591e626fed 100644 --- a/apps/sim/app/api/tools/quiver/text-to-svg/route.ts +++ b/apps/sim/app/api/tools/quiver/text-to-svg/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema, type RawFileInput } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -25,7 +26,7 @@ const RequestSchema = z.object({ presence_penalty: z.number().min(-2).max(2).optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -139,4 +140,4 @@ export async function POST(request: NextRequest) { const message = error instanceof Error ? error.message : 'Unknown error' return NextResponse.json({ success: false, error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/rds/delete/route.ts b/apps/sim/app/api/tools/rds/delete/route.ts index 92b2c9d0b93..7e9f3d9ee12 100644 --- a/apps/sim/app/api/tools/rds/delete/route.ts +++ b/apps/sim/app/api/tools/rds/delete/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeDelete } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSDeleteAPI') @@ -20,7 +21,7 @@ const DeleteSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -77,4 +78,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `RDS delete failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/rds/execute/route.ts b/apps/sim/app/api/tools/rds/execute/route.ts index af6304f98da..48948baeb46 100644 --- a/apps/sim/app/api/tools/rds/execute/route.ts +++ b/apps/sim/app/api/tools/rds/execute/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeStatement } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSExecuteAPI') @@ -17,7 +18,7 @@ const ExecuteSchema = z.object({ query: z.string().min(1, 'Query is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -73,4 +74,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `RDS execute failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/rds/insert/route.ts b/apps/sim/app/api/tools/rds/insert/route.ts index 7fba5fcbb7c..6b147af60ba 100644 --- a/apps/sim/app/api/tools/rds/insert/route.ts +++ b/apps/sim/app/api/tools/rds/insert/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeInsert } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSInsertAPI') @@ -20,7 +21,7 @@ const InsertSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -77,4 +78,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `RDS insert failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/rds/introspect/route.ts b/apps/sim/app/api/tools/rds/introspect/route.ts index 2e8aa42a8ef..8faeb25459e 100644 --- a/apps/sim/app/api/tools/rds/introspect/route.ts +++ b/apps/sim/app/api/tools/rds/introspect/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeIntrospect, type RdsEngine } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSIntrospectAPI') @@ -18,7 +19,7 @@ const IntrospectSchema = z.object({ engine: z.enum(['aurora-postgresql', 'aurora-mysql']).optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -83,4 +84,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/rds/query/route.ts b/apps/sim/app/api/tools/rds/query/route.ts index 21a73291073..871c714ba3c 100644 --- a/apps/sim/app/api/tools/rds/query/route.ts +++ b/apps/sim/app/api/tools/rds/query/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeStatement, validateQuery } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSQueryAPI') @@ -17,7 +18,7 @@ const QuerySchema = z.object({ query: z.string().min(1, 'Query is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -79,4 +80,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `RDS query failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/rds/update/route.ts b/apps/sim/app/api/tools/rds/update/route.ts index 1e2826e4ac7..47dafef3243 100644 --- a/apps/sim/app/api/tools/rds/update/route.ts +++ b/apps/sim/app/api/tools/rds/update/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createRdsClient, executeUpdate } from '@/app/api/tools/rds/utils' const logger = createLogger('RDSUpdateAPI') @@ -23,7 +24,7 @@ const UpdateSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -81,4 +82,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `RDS update failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/redis/execute/route.ts b/apps/sim/app/api/tools/redis/execute/route.ts index 0d59cb58626..5482d0896d2 100644 --- a/apps/sim/app/api/tools/redis/execute/route.ts +++ b/apps/sim/app/api/tools/redis/execute/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('RedisAPI') @@ -13,7 +14,7 @@ const RequestSchema = z.object({ args: z.array(z.union([z.string(), z.number()])).default([]), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { let client: Redis | null = null try { @@ -65,4 +66,4 @@ export async function POST(request: NextRequest) { } } } -} +}) diff --git a/apps/sim/app/api/tools/reducto/parse/route.ts b/apps/sim/app/api/tools/reducto/parse/route.ts index c526c8f2abe..dc92994a48f 100644 --- a/apps/sim/app/api/tools/reducto/parse/route.ts +++ b/apps/sim/app/api/tools/reducto/parse/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' @@ -23,7 +24,7 @@ const ReductoParseSchema = z.object({ tableOutputFormat: z.enum(['html', 'md']).optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -166,4 +167,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/s3/copy-object/route.ts b/apps/sim/app/api/tools/s3/copy-object/route.ts index 0d5c2044a47..e8e3632a908 100644 --- a/apps/sim/app/api/tools/s3/copy-object/route.ts +++ b/apps/sim/app/api/tools/s3/copy-object/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -20,7 +21,7 @@ const S3CopyObjectSchema = z.object({ acl: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -114,4 +115,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/s3/delete-object/route.ts b/apps/sim/app/api/tools/s3/delete-object/route.ts index 6748a1b7be5..01305c0d7fe 100644 --- a/apps/sim/app/api/tools/s3/delete-object/route.ts +++ b/apps/sim/app/api/tools/s3/delete-object/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ const S3DeleteObjectSchema = z.object({ objectKey: z.string().min(1, 'Object key is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -103,4 +104,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/s3/list-objects/route.ts b/apps/sim/app/api/tools/s3/list-objects/route.ts index f13b812e851..6c7f72f4a7e 100644 --- a/apps/sim/app/api/tools/s3/list-objects/route.ts +++ b/apps/sim/app/api/tools/s3/list-objects/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ const S3ListObjectsSchema = z.object({ continuationToken: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -113,4 +114,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/s3/put-object/route.ts b/apps/sim/app/api/tools/s3/put-object/route.ts index c55950bc9a3..2e9dbbed909 100644 --- a/apps/sim/app/api/tools/s3/put-object/route.ts +++ b/apps/sim/app/api/tools/s3/put-object/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -24,7 +25,7 @@ const S3PutObjectSchema = z.object({ acl: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -154,4 +155,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/search/route.ts b/apps/sim/app/api/tools/search/route.ts index b45213115d8..21e33a01cc3 100644 --- a/apps/sim/app/api/tools/search/route.ts +++ b/apps/sim/app/api/tools/search/route.ts @@ -5,6 +5,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { SEARCH_TOOL_COST } from '@/lib/billing/constants' import { env } from '@/lib/core/config/env' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeTool } from '@/tools' const logger = createLogger('search') @@ -16,7 +17,7 @@ const SearchRequestSchema = z.object({ export const maxDuration = 60 export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() try { @@ -130,4 +131,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts index 7cb4a60160b..9d2bb47753c 100644 --- a/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/create-secret/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecret, createSecretsManagerClient } from '../utils' const logger = createLogger('SecretsManagerCreateSecretAPI') @@ -16,7 +17,7 @@ const CreateSecretSchema = z.object({ description: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -62,4 +63,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Failed to create secret: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts index 21a35e64af7..acd9f8205a7 100644 --- a/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/delete-secret/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, deleteSecret } from '../utils' const logger = createLogger('SecretsManagerDeleteSecretAPI') @@ -16,7 +17,7 @@ const DeleteSecretSchema = z.object({ forceDelete: z.boolean().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -68,4 +69,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Failed to delete secret: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts index 22df5b57072..3d2251f57ca 100644 --- a/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/get-secret/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, getSecretValue } from '../utils' const logger = createLogger('SecretsManagerGetSecretAPI') @@ -16,7 +17,7 @@ const GetSecretSchema = z.object({ versionStage: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -67,4 +68,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts b/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts index 58617b4864f..605ebb5023d 100644 --- a/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/list-secrets/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, listSecrets } from '../utils' const logger = createLogger('SecretsManagerListSecretsAPI') @@ -15,7 +16,7 @@ const ListSecretsSchema = z.object({ nextToken: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -58,4 +59,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Failed to list secrets: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts index 5becf7f0dc3..aaccfcb4fd1 100644 --- a/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts +++ b/apps/sim/app/api/tools/secrets_manager/update-secret/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSecretsManagerClient, updateSecretValue } from '../utils' const logger = createLogger('SecretsManagerUpdateSecretAPI') @@ -16,7 +17,7 @@ const UpdateSecretSchema = z.object({ description: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -67,4 +68,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Failed to update secret: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts index 362960b892e..ccb3bb477ae 100644 --- a/apps/sim/app/api/tools/sendgrid/send-mail/route.ts +++ b/apps/sim/app/api/tools/sendgrid/send-mail/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -29,7 +30,7 @@ const SendGridSendMailSchema = z.object({ attachments: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -185,4 +186,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/sftp/delete/route.ts b/apps/sim/app/api/tools/sftp/delete/route.ts index 61c57f17c3c..ed6c77451ca 100644 --- a/apps/sim/app/api/tools/sftp/delete/route.ts +++ b/apps/sim/app/api/tools/sftp/delete/route.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSftpConnection, getFileType, @@ -68,7 +69,7 @@ async function deleteRecursive(sftp: SFTPWrapper, dirPath: string): Promise { const requestId = generateRequestId() try { @@ -185,4 +186,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SFTP delete failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sftp/download/route.ts b/apps/sim/app/api/tools/sftp/download/route.ts index 849e1ee0947..a430fd679c3 100644 --- a/apps/sim/app/api/tools/sftp/download/route.ts +++ b/apps/sim/app/api/tools/sftp/download/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { createSftpConnection, getSftp, isPathSafe, sanitizePath } from '@/app/api/tools/sftp/utils' @@ -22,7 +23,7 @@ const DownloadSchema = z.object({ encoding: z.enum(['utf-8', 'base64']).default('utf-8'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -155,4 +156,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SFTP download failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sftp/list/route.ts b/apps/sim/app/api/tools/sftp/list/route.ts index ec5e3c85c15..bb1e5404ab2 100644 --- a/apps/sim/app/api/tools/sftp/list/route.ts +++ b/apps/sim/app/api/tools/sftp/list/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSftpConnection, getFileType, @@ -27,7 +28,7 @@ const ListSchema = z.object({ detailed: z.boolean().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -153,4 +154,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SFTP list failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sftp/mkdir/route.ts b/apps/sim/app/api/tools/sftp/mkdir/route.ts index 50ec7ea2a91..c9a2905efd5 100644 --- a/apps/sim/app/api/tools/sftp/mkdir/route.ts +++ b/apps/sim/app/api/tools/sftp/mkdir/route.ts @@ -4,6 +4,7 @@ import type { SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSftpConnection, getSftp, @@ -56,7 +57,7 @@ async function mkdirRecursive(sftp: SFTPWrapper, dirPath: string): Promise } } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -165,4 +166,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SFTP mkdir failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sftp/upload/route.ts b/apps/sim/app/api/tools/sftp/upload/route.ts index 54851e595b6..c915ec22e9b 100644 --- a/apps/sim/app/api/tools/sftp/upload/route.ts +++ b/apps/sim/app/api/tools/sftp/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -34,7 +35,7 @@ const UploadSchema = z.object({ permissions: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -233,4 +234,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SFTP upload failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts index fbbbaab6817..9265d8aff61 100644 --- a/apps/sim/app/api/tools/sharepoint/lists/route.ts +++ b/apps/sim/app/api/tools/sharepoint/lists/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateSharePointSiteId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ interface SharePointList { } } -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { @@ -88,4 +89,4 @@ export async function POST(request: Request) { logger.error(`[${requestId}] Error fetching lists from SharePoint`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts index 7afc3954fa2..2e463ef993d 100644 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ b/apps/sim/app/api/tools/sharepoint/site/route.ts @@ -6,13 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('SharePointSiteAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -116,4 +117,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching site from SharePoint`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts index 2119fe975c6..14fd022fe42 100644 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ b/apps/sim/app/api/tools/sharepoint/sites/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import type { SharepointSite } from '@/tools/sharepoint/types' @@ -9,7 +10,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('SharePointSitesAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { @@ -75,4 +76,4 @@ export async function POST(request: Request) { logger.error(`[${requestId}] Error fetching sites from SharePoint`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sharepoint/upload/route.ts b/apps/sim/app/api/tools/sharepoint/upload/route.ts index df8f3371283..b7c08dd7a32 100644 --- a/apps/sim/app/api/tools/sharepoint/upload/route.ts +++ b/apps/sim/app/api/tools/sharepoint/upload/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -22,7 +23,7 @@ const SharepointUploadSchema = z.object({ files: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -275,4 +276,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/add-reaction/route.ts b/apps/sim/app/api/tools/slack/add-reaction/route.ts index 18f825270ff..f9665cb6b4c 100644 --- a/apps/sim/app/api/tools/slack/add-reaction/route.ts +++ b/apps/sim/app/api/tools/slack/add-reaction/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -11,7 +12,7 @@ const SlackAddReactionSchema = z.object({ name: z.string().min(1, 'Emoji name is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -84,4 +85,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index b96badeba3a..7d37f4197d2 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ interface SlackChannel { is_member: boolean } -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -148,7 +149,7 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) async function fetchSlackChannels(accessToken: string, includePrivate = true) { const url = new URL('https://slack.com/api/conversations.list') diff --git a/apps/sim/app/api/tools/slack/delete-message/route.ts b/apps/sim/app/api/tools/slack/delete-message/route.ts index e21324f2921..dc6ecb4b071 100644 --- a/apps/sim/app/api/tools/slack/delete-message/route.ts +++ b/apps/sim/app/api/tools/slack/delete-message/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -10,7 +11,7 @@ const SlackDeleteMessageSchema = z.object({ timestamp: z.string().min(1, 'Message timestamp is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -81,4 +82,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/download/route.ts b/apps/sim/app/api/tools/slack/download/route.ts index 83a44386d4d..5885206bd2a 100644 --- a/apps/sim/app/api/tools/slack/download/route.ts +++ b/apps/sim/app/api/tools/slack/download/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ const SlackDownloadSchema = z.object({ fileName: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -173,4 +174,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/read-messages/route.ts b/apps/sim/app/api/tools/slack/read-messages/route.ts index a91c8e8e0e0..383bd11bde6 100644 --- a/apps/sim/app/api/tools/slack/read-messages/route.ts +++ b/apps/sim/app/api/tools/slack/read-messages/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { openDMChannel } from '../utils' export const dynamic = 'force-dynamic' @@ -27,7 +28,7 @@ const SlackReadMessagesSchema = z message: 'Either channel or userId is required', }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -209,4 +210,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/remove-reaction/route.ts b/apps/sim/app/api/tools/slack/remove-reaction/route.ts index 13281336bda..bdb1a8ef9e9 100644 --- a/apps/sim/app/api/tools/slack/remove-reaction/route.ts +++ b/apps/sim/app/api/tools/slack/remove-reaction/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -11,7 +12,7 @@ const SlackRemoveReactionSchema = z.object({ name: z.string().min(1, 'Emoji name is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -84,4 +85,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/send-ephemeral/route.ts b/apps/sim/app/api/tools/slack/send-ephemeral/route.ts index 1387290c6ae..c06b1790284 100644 --- a/apps/sim/app/api/tools/slack/send-ephemeral/route.ts +++ b/apps/sim/app/api/tools/slack/send-ephemeral/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ const SlackSendEphemeralSchema = z.object({ blocks: z.array(z.record(z.unknown())).optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -99,4 +100,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/send-message/route.ts b/apps/sim/app/api/tools/slack/send-message/route.ts index 5520a280f6e..1c227db0ec9 100644 --- a/apps/sim/app/api/tools/slack/send-message/route.ts +++ b/apps/sim/app/api/tools/slack/send-message/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { sendSlackMessage } from '../utils' @@ -24,7 +25,7 @@ const SlackSendMessageSchema = z message: 'Either channel or userId is required', }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -92,4 +93,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/update-message/route.ts b/apps/sim/app/api/tools/slack/update-message/route.ts index ccf0a045294..e38d9c3cb7f 100644 --- a/apps/sim/app/api/tools/slack/update-message/route.ts +++ b/apps/sim/app/api/tools/slack/update-message/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' @@ -16,7 +17,7 @@ const SlackUpdateMessageSchema = z.object({ blocks: z.array(z.record(z.unknown())).optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -122,4 +123,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts index 7b116205856..6accc49d91f 100644 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ b/apps/sim/app/api/tools/slack/users/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -17,7 +18,7 @@ interface SlackUser { is_bot: boolean } -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -105,7 +106,7 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) async function fetchSlackUser(accessToken: string, userId: string) { const url = new URL('https://slack.com/api/users.info') diff --git a/apps/sim/app/api/tools/sms/send/route.ts b/apps/sim/app/api/tools/sms/send/route.ts index c43a1bec1fc..5a1c6701b60 100644 --- a/apps/sim/app/api/tools/sms/send/route.ts +++ b/apps/sim/app/api/tools/sms/send/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type SMSOptions, sendSMS } from '@/lib/messaging/sms/service' export const dynamic = 'force-dynamic' @@ -15,7 +16,7 @@ const SMSSendSchema = z.object({ body: z.string().min(1, 'SMS body is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -96,4 +97,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/smtp/send/route.ts b/apps/sim/app/api/tools/smtp/send/route.ts index dc5c4fb28c0..52ca415ec7b 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -33,7 +34,7 @@ const SmtpSendSchema = z.object({ attachments: RawFileInputArraySchema.optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -236,4 +237,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/sqs/send/route.ts b/apps/sim/app/api/tools/sqs/send/route.ts index c9078aecc4b..034d7ccbf7a 100644 --- a/apps/sim/app/api/tools/sqs/send/route.ts +++ b/apps/sim/app/api/tools/sqs/send/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSqsClient, sendMessage } from '../utils' const logger = createLogger('SQSSendMessageAPI') @@ -19,7 +20,7 @@ const SendMessageSchema = z.object({ }), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const auth = await checkInternalAuth(request) @@ -73,4 +74,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SQS send message failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts index 186b4c390aa..202daf0988b 100644 --- a/apps/sim/app/api/tools/ssh/check-command-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-command-exists/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHCheckCommandExistsAPI') @@ -17,7 +18,7 @@ const CheckCommandExistsSchema = z.object({ commandName: z.string().min(1, 'Command name is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -108,4 +109,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts index e7e65cc633b..bbeb9604eb9 100644 --- a/apps/sim/app/api/tools/ssh/check-file-exists/route.ts +++ b/apps/sim/app/api/tools/ssh/check-file-exists/route.ts @@ -4,6 +4,7 @@ import type { Client, SFTPWrapper, Stats } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, getFileType, @@ -36,7 +37,7 @@ function getSFTP(client: Client): Promise { }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -137,4 +138,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/create-directory/route.ts b/apps/sim/app/api/tools/ssh/create-directory/route.ts index 467c097d4e3..e49b1846f8d 100644 --- a/apps/sim/app/api/tools/ssh/create-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/create-directory/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, @@ -24,7 +25,7 @@ const CreateDirectorySchema = z.object({ permissions: z.string().default('0755'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -111,4 +112,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/delete-file/route.ts b/apps/sim/app/api/tools/ssh/delete-file/route.ts index 44506996b07..c436cb62b44 100644 --- a/apps/sim/app/api/tools/ssh/delete-file/route.ts +++ b/apps/sim/app/api/tools/ssh/delete-file/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, @@ -24,7 +25,7 @@ const DeleteFileSchema = z.object({ force: z.boolean().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -104,4 +105,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SSH delete file failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/download-file/route.ts b/apps/sim/app/api/tools/ssh/download-file/route.ts index 6dbfdf3c5ee..4878e53a56a 100644 --- a/apps/sim/app/api/tools/ssh/download-file/route.ts +++ b/apps/sim/app/api/tools/ssh/download-file/route.ts @@ -5,6 +5,7 @@ import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' @@ -32,7 +33,7 @@ function getSFTP(client: Client): Promise { }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -149,4 +150,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/execute-command/route.ts b/apps/sim/app/api/tools/ssh/execute-command/route.ts index 66b5dfb1555..8cb9f5b72e5 100644 --- a/apps/sim/app/api/tools/ssh/execute-command/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-command/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, @@ -23,7 +24,7 @@ const ExecuteCommandSchema = z.object({ workingDirectory: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -92,4 +93,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/execute-script/route.ts b/apps/sim/app/api/tools/ssh/execute-script/route.ts index b0158e43fdd..b3763801f54 100644 --- a/apps/sim/app/api/tools/ssh/execute-script/route.ts +++ b/apps/sim/app/api/tools/ssh/execute-script/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHExecuteScriptAPI') @@ -19,7 +20,7 @@ const ExecuteScriptSchema = z.object({ workingDirectory: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -105,4 +106,4 @@ exit $exit_code` { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/get-system-info/route.ts b/apps/sim/app/api/tools/ssh/get-system-info/route.ts index 65a25e82e21..e04ee8ecc0b 100644 --- a/apps/sim/app/api/tools/ssh/get-system-info/route.ts +++ b/apps/sim/app/api/tools/ssh/get-system-info/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHGetSystemInfoAPI') @@ -16,7 +17,7 @@ const GetSystemInfoSchema = z.object({ passphrase: z.string().nullish(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -128,4 +129,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/list-directory/route.ts b/apps/sim/app/api/tools/ssh/list-directory/route.ts index 2971ef9202b..b98616efbe6 100644 --- a/apps/sim/app/api/tools/ssh/list-directory/route.ts +++ b/apps/sim/app/api/tools/ssh/list-directory/route.ts @@ -4,6 +4,7 @@ import type { Client, FileEntry, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, getFileType, @@ -57,7 +58,7 @@ async function listDir(sftp: SFTPWrapper, dirPath: string): Promise }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -135,4 +136,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/move-rename/route.ts b/apps/sim/app/api/tools/ssh/move-rename/route.ts index 98285041707..726255950ce 100644 --- a/apps/sim/app/api/tools/ssh/move-rename/route.ts +++ b/apps/sim/app/api/tools/ssh/move-rename/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, escapeShellArg, @@ -24,7 +25,7 @@ const MoveRenameSchema = z.object({ overwrite: z.boolean().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -122,4 +123,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SSH move/rename failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/read-file-content/route.ts b/apps/sim/app/api/tools/ssh/read-file-content/route.ts index 7493a6cb10c..7fbabed501b 100644 --- a/apps/sim/app/api/tools/ssh/read-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/read-file-content/route.ts @@ -4,6 +4,7 @@ import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHReadFileContentAPI') @@ -32,7 +33,7 @@ function getSFTP(client: Client): Promise { }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -136,4 +137,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/upload-file/route.ts b/apps/sim/app/api/tools/ssh/upload-file/route.ts index 2020271465b..6b12de73bec 100644 --- a/apps/sim/app/api/tools/ssh/upload-file/route.ts +++ b/apps/sim/app/api/tools/ssh/upload-file/route.ts @@ -4,6 +4,7 @@ import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHUploadFileAPI') @@ -34,7 +35,7 @@ function getSFTP(client: Client): Promise { }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -133,4 +134,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `SSH file upload failed: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/ssh/write-file-content/route.ts b/apps/sim/app/api/tools/ssh/write-file-content/route.ts index 77a180b9dae..29aa0bf1cc7 100644 --- a/apps/sim/app/api/tools/ssh/write-file-content/route.ts +++ b/apps/sim/app/api/tools/ssh/write-file-content/route.ts @@ -4,6 +4,7 @@ import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils' const logger = createLogger('SSHWriteFileContentAPI') @@ -33,7 +34,7 @@ function getSFTP(client: Client): Promise { }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -155,4 +156,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/stagehand/agent/route.ts b/apps/sim/app/api/tools/stagehand/agent/route.ts index c0a804a3dde..afc32d5bc6a 100644 --- a/apps/sim/app/api/tools/stagehand/agent/route.ts +++ b/apps/sim/app/api/tools/stagehand/agent/route.ts @@ -5,6 +5,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' const logger = createLogger('StagehandAgentAPI') @@ -92,7 +93,7 @@ function substituteVariables(text: string, variables: Record | u return result } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -353,4 +354,4 @@ export async function POST(request: NextRequest) { } } } -} +}) diff --git a/apps/sim/app/api/tools/stagehand/extract/route.ts b/apps/sim/app/api/tools/stagehand/extract/route.ts index 4dd862039b0..c39f5c78534 100644 --- a/apps/sim/app/api/tools/stagehand/extract/route.ts +++ b/apps/sim/app/api/tools/stagehand/extract/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' const logger = createLogger('StagehandExtractAPI') @@ -23,7 +24,7 @@ const requestSchema = z.object({ url: z.string().url(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await checkInternalAuth(request) if (!auth.success || !auth.userId) { return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) @@ -251,4 +252,4 @@ export async function POST(request: NextRequest) { } } } -} +}) diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index e45bb273f06..0cc485e7143 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -8,6 +8,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, @@ -45,7 +46,7 @@ interface SttRequestBody { executionId?: string } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] STT transcription request started`) @@ -305,7 +306,7 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) async function transcribeWithWhisper( audioBuffer: Buffer, diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index c0677bb35aa..d8d31f0b452 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileInputSchema } from '@/lib/uploads/utils/file-schemas' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -22,7 +23,7 @@ const SupabaseStorageUploadSchema = z.object({ upsert: z.boolean().optional().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -255,4 +256,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/telegram/send-document/route.ts b/apps/sim/app/api/tools/telegram/send-document/route.ts index 0ddaac702a5..738a35e9adc 100644 --- a/apps/sim/app/api/tools/telegram/send-document/route.ts +++ b/apps/sim/app/api/tools/telegram/send-document/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' @@ -19,7 +20,7 @@ const TelegramSendDocumentSchema = z.object({ caption: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -157,4 +158,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index c1986a4de90..c2c3bed5d7e 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -10,6 +10,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { @@ -311,7 +312,7 @@ async function pollForJobCompletion( ) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -656,4 +657,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/thinking/route.ts b/apps/sim/app/api/tools/thinking/route.ts index 8b397db5eec..b9396b93621 100644 --- a/apps/sim/app/api/tools/thinking/route.ts +++ b/apps/sim/app/api/tools/thinking/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { ThinkingToolParams, ThinkingToolResponse } from '@/tools/thinking/types' const logger = createLogger('ThinkingToolAPI') @@ -11,7 +12,7 @@ export const dynamic = 'force-dynamic' * POST - Process a thinking tool request * Simply acknowledges the thought by returning it in the output */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -51,4 +52,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts index fb4ca52738a..33c1efcf9ab 100644 --- a/apps/sim/app/api/tools/trello/boards/route.ts +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('TrelloBoardsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const apiKey = process.env.TRELLO_API_KEY @@ -84,4 +85,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/tts/route.ts b/apps/sim/app/api/tools/tts/route.ts index 153925c4079..84007103d0f 100644 --- a/apps/sim/app/api/tools/tts/route.ts +++ b/apps/sim/app/api/tools/tts/route.ts @@ -5,11 +5,12 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { StorageService } from '@/lib/uploads' const logger = createLogger('ProxyTTSAPI') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success) { @@ -142,4 +143,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/tts/unified/route.ts b/apps/sim/app/api/tools/tts/unified/route.ts index 5b6ba35198a..9cc52ff7c67 100644 --- a/apps/sim/app/api/tools/tts/unified/route.ts +++ b/apps/sim/app/api/tools/tts/unified/route.ts @@ -5,6 +5,7 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { StorageService } from '@/lib/uploads' import type { AzureTtsParams, @@ -83,7 +84,7 @@ interface TtsUnifiedRequestBody { executionId?: string } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] TTS unified request started`) @@ -313,7 +314,7 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) async function synthesizeWithOpenAi( params: OpenAiTtsParams diff --git a/apps/sim/app/api/tools/twilio/get-recording/route.ts b/apps/sim/app/api/tools/twilio/get-recording/route.ts index b5562307e84..4efd33a3b57 100644 --- a/apps/sim/app/api/tools/twilio/get-recording/route.ts +++ b/apps/sim/app/api/tools/twilio/get-recording/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' export const dynamic = 'force-dynamic' @@ -49,7 +50,7 @@ const TwilioGetRecordingSchema = z.object({ recordingSid: z.string().min(1, 'Recording SID is required'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -247,4 +248,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 3a5a5b817f7..38cbd7a31ca 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import type { UserFile } from '@/executor/types' import type { VideoRequestBody } from '@/tools/video/types' @@ -12,7 +13,7 @@ const logger = createLogger('VideoProxyAPI') export const dynamic = 'force-dynamic' export const maxDuration = 600 // 10 minutes for video generation -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() logger.info(`[${requestId}] Video generation request started`) @@ -270,7 +271,7 @@ export async function POST(request: NextRequest) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) async function generateWithRunway( apiKey: string, diff --git a/apps/sim/app/api/tools/vision/analyze/route.ts b/apps/sim/app/api/tools/vision/analyze/route.ts index 08071e9f630..7890669d540 100644 --- a/apps/sim/app/api/tools/vision/analyze/route.ts +++ b/apps/sim/app/api/tools/vision/analyze/route.ts @@ -8,6 +8,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { @@ -28,7 +29,7 @@ const VisionAnalyzeSchema = z.object({ prompt: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -361,4 +362,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts index ae2afd4cc0e..d25bf495bc0 100644 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ b/apps/sim/app/api/tools/wealthbox/item/route.ts @@ -6,13 +6,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' const logger = createLogger('WealthboxItemAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -161,4 +162,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Wealthbox item`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts index efdda2b3c5f..f78e7273d84 100644 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ b/apps/sim/app/api/tools/wealthbox/items/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -24,7 +25,7 @@ interface WealthboxItem { /** * Get items (notes, contacts, tasks) from Wealthbox */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -192,4 +193,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching Wealthbox items`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts index 8562da8ac19..0baf1f7a05f 100644 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ b/apps/sim/app/api/tools/webflow/collections/route.ts @@ -3,13 +3,14 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowCollectionsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -89,4 +90,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts index b2c55121679..fa62a38b92a 100644 --- a/apps/sim/app/api/tools/webflow/items/route.ts +++ b/apps/sim/app/api/tools/webflow/items/route.ts @@ -3,13 +3,14 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowItemsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -103,4 +104,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts index 47959f4c93f..012f45d5828 100644 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ b/apps/sim/app/api/tools/webflow/sites/route.ts @@ -3,13 +3,14 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebflowSitesAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -101,4 +102,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/wordpress/upload/route.ts b/apps/sim/app/api/tools/wordpress/upload/route.ts index 5cf9a1b6f62..a18733b1e69 100644 --- a/apps/sim/app/api/tools/wordpress/upload/route.ts +++ b/apps/sim/app/api/tools/wordpress/upload/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' import { getFileExtension, @@ -28,7 +29,7 @@ const WordPressUploadSchema = z.object({ description: z.string().optional().nullable(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -222,4 +223,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/assign-onboarding/route.ts b/apps/sim/app/api/tools/workday/assign-onboarding/route.ts index c04e1c65db5..5b51536fe35 100644 --- a/apps/sim/app/api/tools/workday/assign-onboarding/route.ts +++ b/apps/sim/app/api/tools/workday/assign-onboarding/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap' export const dynamic = 'force-dynamic' @@ -19,7 +20,7 @@ const RequestSchema = z.object({ actionEventId: z.string().min(1), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -64,4 +65,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/change-job/route.ts b/apps/sim/app/api/tools/workday/change-job/route.ts index 6858a49a649..e9fd133efa1 100644 --- a/apps/sim/app/api/tools/workday/change-job/route.ts +++ b/apps/sim/app/api/tools/workday/change-job/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap' export const dynamic = 'force-dynamic' @@ -23,7 +24,7 @@ const RequestSchema = z.object({ reason: z.string().min(1, 'Reason is required for job changes'), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -91,4 +92,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/create-prehire/route.ts b/apps/sim/app/api/tools/workday/create-prehire/route.ts index d9a955b4187..48aa4926f9d 100644 --- a/apps/sim/app/api/tools/workday/create-prehire/route.ts +++ b/apps/sim/app/api/tools/workday/create-prehire/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap' export const dynamic = 'force-dynamic' @@ -21,7 +22,7 @@ const RequestSchema = z.object({ countryCode: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -131,4 +132,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/get-compensation/route.ts b/apps/sim/app/api/tools/workday/get-compensation/route.ts index a78a1619933..46217281488 100644 --- a/apps/sim/app/api/tools/workday/get-compensation/route.ts +++ b/apps/sim/app/api/tools/workday/get-compensation/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, @@ -24,7 +25,7 @@ const RequestSchema = z.object({ workerId: z.string().min(1), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -98,4 +99,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/get-organizations/route.ts b/apps/sim/app/api/tools/workday/get-organizations/route.ts index 93adddd0b86..063803c2aba 100644 --- a/apps/sim/app/api/tools/workday/get-organizations/route.ts +++ b/apps/sim/app/api/tools/workday/get-organizations/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, @@ -24,7 +25,7 @@ const RequestSchema = z.object({ offset: z.number().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -91,4 +92,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/get-worker/route.ts b/apps/sim/app/api/tools/workday/get-worker/route.ts index 904c5cf4132..6a118023824 100644 --- a/apps/sim/app/api/tools/workday/get-worker/route.ts +++ b/apps/sim/app/api/tools/workday/get-worker/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, @@ -22,7 +23,7 @@ const RequestSchema = z.object({ workerId: z.string().min(1), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -84,4 +85,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/hire/route.ts b/apps/sim/app/api/tools/workday/hire/route.ts index 1c6c8abc8b8..393998d996a 100644 --- a/apps/sim/app/api/tools/workday/hire/route.ts +++ b/apps/sim/app/api/tools/workday/hire/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap' export const dynamic = 'force-dynamic' @@ -20,7 +21,7 @@ const RequestSchema = z.object({ employeeType: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -75,4 +76,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/list-workers/route.ts b/apps/sim/app/api/tools/workday/list-workers/route.ts index e8f31950367..15fc6715648 100644 --- a/apps/sim/app/api/tools/workday/list-workers/route.ts +++ b/apps/sim/app/api/tools/workday/list-workers/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, @@ -23,7 +24,7 @@ const RequestSchema = z.object({ offset: z.number().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -80,4 +81,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/terminate/route.ts b/apps/sim/app/api/tools/workday/terminate/route.ts index 8484d781a03..92ccf22ae29 100644 --- a/apps/sim/app/api/tools/workday/terminate/route.ts +++ b/apps/sim/app/api/tools/workday/terminate/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap' export const dynamic = 'force-dynamic' @@ -21,7 +22,7 @@ const RequestSchema = z.object({ lastDayOfWork: z.string().optional(), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -74,4 +75,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/workday/update-worker/route.ts b/apps/sim/app/api/tools/workday/update-worker/route.ts index dbf2f1c5799..33c4759859f 100644 --- a/apps/sim/app/api/tools/workday/update-worker/route.ts +++ b/apps/sim/app/api/tools/workday/update-worker/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkdaySoapClient, extractRefId, wdRef } from '@/tools/workday/soap' export const dynamic = 'force-dynamic' @@ -18,7 +19,7 @@ const RequestSchema = z.object({ fields: z.record(z.unknown()), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -63,4 +64,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/zoom/get-recordings/route.ts b/apps/sim/app/api/tools/zoom/get-recordings/route.ts index 2247612fd25..2c521a77c30 100644 --- a/apps/sim/app/api/tools/zoom/get-recordings/route.ts +++ b/apps/sim/app/api/tools/zoom/get-recordings/route.ts @@ -7,6 +7,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' export const dynamic = 'force-dynamic' @@ -55,7 +56,7 @@ const ZoomGetRecordingsSchema = z.object({ downloadFiles: z.boolean().optional().default(false), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -213,4 +214,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts index 01360af7610..3e7db3d2a22 100644 --- a/apps/sim/app/api/tools/zoom/meetings/route.ts +++ b/apps/sim/app/api/tools/zoom/meetings/route.ts @@ -2,13 +2,14 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('ZoomMeetingsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { const body = await request.json() @@ -79,4 +80,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/usage/route.ts b/apps/sim/app/api/usage/route.ts index 12d98d57cc3..86c00e4658b 100644 --- a/apps/sim/app/api/usage/route.ts +++ b/apps/sim/app/api/usage/route.ts @@ -8,6 +8,7 @@ import { isOrganizationOwnerOrAdmin, } from '@/lib/billing/core/organization' import { isUserMemberOfOrganization } from '@/lib/billing/organizations/membership' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UnifiedUsageAPI') @@ -28,7 +29,7 @@ const usageUpdateSchema = z * GET/PUT /api/usage?context=user|organization&userId=&organizationId= * */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() try { @@ -95,9 +96,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function PUT(request: NextRequest) { +export const PUT = withRouteHandler(async (request: NextRequest) => { const session = await getSession() try { @@ -157,4 +158,4 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/users/me/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts index d5a88315442..e604fe5835b 100644 --- a/apps/sim/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -6,58 +6,58 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ApiKeyAPI') // DELETE /api/users/me/api-keys/[id] - Delete an API key -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const keyId = id + + if (!keyId) { + return NextResponse.json({ error: 'API key ID is required' }, { status: 400 }) + } + + // Delete the API key, ensuring it belongs to the current user + const result = await db + .delete(apiKey) + .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) + .returning({ id: apiKey.id, name: apiKey.name }) + + if (!result.length) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }) + } + + const deletedKey = result[0] + + recordAudit({ + workspaceId: null, + actorId: userId, + action: AuditAction.PERSONAL_API_KEY_REVOKED, + resourceType: AuditResourceType.API_KEY, + resourceId: keyId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: deletedKey.name, + description: `Revoked personal API key: ${deletedKey.name}`, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to delete API key', { error }) + return NextResponse.json({ error: 'Failed to delete API key' }, { status: 500 }) } - - const userId = session.user.id - const keyId = id - - if (!keyId) { - return NextResponse.json({ error: 'API key ID is required' }, { status: 400 }) - } - - // Delete the API key, ensuring it belongs to the current user - const result = await db - .delete(apiKey) - .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) - .returning({ id: apiKey.id, name: apiKey.name }) - - if (!result.length) { - return NextResponse.json({ error: 'API key not found' }, { status: 404 }) - } - - const deletedKey = result[0] - - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.PERSONAL_API_KEY_REVOKED, - resourceType: AuditResourceType.API_KEY, - resourceId: keyId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: deletedKey.name, - description: `Revoked personal API key: ${deletedKey.name}`, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to delete API key', { error }) - return NextResponse.json({ error: 'Failed to delete API key' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index 173bc01be7a..7399766d9ed 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -7,11 +7,12 @@ import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('ApiKeysAPI') // GET /api/users/me/api-keys - Get all API keys for the current user -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -49,10 +50,10 @@ export async function GET(request: NextRequest) { logger.error('Failed to fetch API keys', { error }) return NextResponse.json({ error: 'Failed to fetch API keys' }, { status: 500 }) } -} +}) // POST /api/users/me/api-keys - Create a new API key -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -134,4 +135,4 @@ export async function POST(request: NextRequest) { logger.error('Failed to create API key', { error }) return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts index 1b627dbac1d..c2a7a3452a9 100644 --- a/apps/sim/app/api/users/me/profile/route.ts +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UpdateUserProfileAPI') @@ -34,7 +35,7 @@ interface UpdateData { export const dynamic = 'force-dynamic' -export async function PATCH(request: NextRequest) { +export const PATCH = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -92,10 +93,10 @@ export async function PATCH(request: NextRequest) { logger.error(`[${requestId}] Profile update error`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) // GET endpoint to fetch current user profile -export async function GET() { +export const GET = withRouteHandler(async () => { const requestId = generateRequestId() try { @@ -131,4 +132,4 @@ export async function GET() { logger.error(`[${requestId}] Profile fetch error`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 84876b67071..32a894d680a 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UserSettingsAPI') @@ -43,7 +44,7 @@ const defaultSettings = { showActionBar: true, } -export async function GET() { +export const GET = withRouteHandler(async () => { const requestId = generateRequestId() try { @@ -84,9 +85,9 @@ export async function GET() { logger.error(`[${requestId}] Settings fetch error`, error) return NextResponse.json({ data: defaultSettings }, { status: 200 }) } -} +}) -export async function PATCH(request: Request) { +export const PATCH = withRouteHandler(async (request: Request) => { const requestId = generateRequestId() try { @@ -138,4 +139,4 @@ export async function PATCH(request: Request) { logger.error(`[${requestId}] Settings update error`, error) return NextResponse.json({ success: true }, { status: 200 }) } -} +}) diff --git a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts index 30c77999955..558e8a3874b 100644 --- a/apps/sim/app/api/users/me/settings/unsubscribe/route.ts +++ b/apps/sim/app/api/users/me/settings/unsubscribe/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { EmailType } from '@/lib/messaging/email/mailer' import { getEmailPreferences, @@ -19,7 +20,7 @@ const unsubscribeSchema = z.object({ type: z.enum(['all', 'marketing', 'updates', 'notifications']).optional().default('all'), }) -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -59,9 +60,9 @@ export async function GET(req: NextRequest) { logger.error(`[${requestId}] Error processing unsubscribe GET request:`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -160,4 +161,4 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error processing unsubscribe POST request:`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts index fad7ddb9a81..ddcbafa273c 100644 --- a/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts +++ b/apps/sim/app/api/users/me/subscription/[id]/transfer/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { hasPaidSubscription } from '@/lib/billing' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SubscriptionTransferAPI') @@ -13,109 +14,111 @@ const transferSubscriptionSchema = z.object({ organizationId: z.string().min(1), }) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const subscriptionId = (await params).id - const session = await getSession() - - if (!session?.user?.id) { - logger.warn('Unauthorized subscription transfer attempt') - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - let body +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { try { - body = await request.json() - } catch (_parseError) { - return NextResponse.json( - { - error: 'Invalid JSON in request body', - }, - { status: 400 } - ) - } - - const validationResult = transferSubscriptionSchema.safeParse(body) - if (!validationResult.success) { - return NextResponse.json( - { - error: 'Invalid request parameters', - details: validationResult.error.format(), - }, - { status: 400 } - ) + const subscriptionId = (await params).id + const session = await getSession() + + if (!session?.user?.id) { + logger.warn('Unauthorized subscription transfer attempt') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body + try { + body = await request.json() + } catch (_parseError) { + return NextResponse.json( + { + error: 'Invalid JSON in request body', + }, + { status: 400 } + ) + } + + const validationResult = transferSubscriptionSchema.safeParse(body) + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: validationResult.error.format(), + }, + { status: 400 } + ) + } + + const { organizationId } = validationResult.data + logger.info('Processing subscription transfer', { subscriptionId, organizationId }) + + const sub = await db + .select() + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + .then((rows) => rows[0]) + + if (!sub) { + return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) + } + + if (sub.referenceId !== session.user.id) { + return NextResponse.json( + { error: 'Unauthorized - subscription does not belong to user' }, + { status: 403 } + ) + } + + const org = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .then((rows) => rows[0]) + + if (!org) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const mem = await db + .select() + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .then((rows) => rows[0]) + + if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) { + return NextResponse.json( + { error: 'Unauthorized - user is not admin of organization' }, + { status: 403 } + ) + } + + // Check if org already has an active subscription (prevent duplicates) + if (await hasPaidSubscription(organizationId)) { + return NextResponse.json( + { error: 'Organization already has an active subscription' }, + { status: 409 } + ) + } + + await db + .update(subscription) + .set({ referenceId: organizationId }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Subscription transfer completed', { + subscriptionId, + organizationId, + userId: session.user.id, + }) + + return NextResponse.json({ + success: true, + message: 'Subscription transferred successfully', + }) + } catch (error) { + logger.error('Error transferring subscription', { + error: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 }) } - - const { organizationId } = validationResult.data - logger.info('Processing subscription transfer', { subscriptionId, organizationId }) - - const sub = await db - .select() - .from(subscription) - .where(eq(subscription.id, subscriptionId)) - .then((rows) => rows[0]) - - if (!sub) { - return NextResponse.json({ error: 'Subscription not found' }, { status: 404 }) - } - - if (sub.referenceId !== session.user.id) { - return NextResponse.json( - { error: 'Unauthorized - subscription does not belong to user' }, - { status: 403 } - ) - } - - const org = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .then((rows) => rows[0]) - - if (!org) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } - - const mem = await db - .select() - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) - .then((rows) => rows[0]) - - if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) { - return NextResponse.json( - { error: 'Unauthorized - user is not admin of organization' }, - { status: 403 } - ) - } - - // Check if org already has an active subscription (prevent duplicates) - if (await hasPaidSubscription(organizationId)) { - return NextResponse.json( - { error: 'Organization already has an active subscription' }, - { status: 409 } - ) - } - - await db - .update(subscription) - .set({ referenceId: organizationId }) - .where(eq(subscription.id, subscriptionId)) - - logger.info('Subscription transfer completed', { - subscriptionId, - organizationId, - userId: session.user.id, - }) - - return NextResponse.json({ - success: true, - message: 'Subscription transferred successfully', - }) - } catch (error) { - logger.error('Error transferring subscription', { - error: error instanceof Error ? error.message : String(error), - }) - return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/users/me/usage-limits/route.ts b/apps/sim/app/api/users/me/usage-limits/route.ts index 19e3403da09..015605bf049 100644 --- a/apps/sim/app/api/users/me/usage-limits/route.ts +++ b/apps/sim/app/api/users/me/usage-limits/route.ts @@ -6,11 +6,12 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage' import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage' import { RateLimiter } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createErrorResponse } from '@/app/api/workflows/utils' const logger = createLogger('UsageLimitsAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkHybridAuth(request, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -79,4 +80,4 @@ export async function GET(request: NextRequest) { logger.error('Error checking usage limits:', error) return createErrorResponse(error.message || 'Failed to check usage limits', 500) } -} +}) diff --git a/apps/sim/app/api/users/me/usage-logs/route.ts b/apps/sim/app/api/users/me/usage-logs/route.ts index e95b6fc03ae..82e44da733c 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { getUserUsageLogs, type UsageLogSource } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('UsageLogsAPI') @@ -19,7 +20,7 @@ const QuerySchema = z.object({ * GET /api/users/me/usage-logs * Get usage logs for the authenticated user */ -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) @@ -119,4 +120,4 @@ export async function GET(req: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/v1/admin/access-control/route.ts b/apps/sim/app/api/v1/admin/access-control/route.ts index 7da37edc8e0..e0f18bd2094 100644 --- a/apps/sim/app/api/v1/admin/access-control/route.ts +++ b/apps/sim/app/api/v1/admin/access-control/route.ts @@ -23,6 +23,7 @@ import { db } from '@sim/db' import { organization, permissionGroup, permissionGroupMember, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq, inArray, sql } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -44,126 +45,130 @@ export interface AdminPermissionGroup { createdByEmail: string | null } -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const organizationId = url.searchParams.get('organizationId') - - try { - const baseQuery = db - .select({ - id: permissionGroup.id, - organizationId: permissionGroup.organizationId, - organizationName: organization.name, - name: permissionGroup.name, - description: permissionGroup.description, - createdAt: permissionGroup.createdAt, - createdByUserId: permissionGroup.createdBy, - createdByEmail: user.email, +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const organizationId = url.searchParams.get('organizationId') + + try { + const baseQuery = db + .select({ + id: permissionGroup.id, + organizationId: permissionGroup.organizationId, + organizationName: organization.name, + name: permissionGroup.name, + description: permissionGroup.description, + createdAt: permissionGroup.createdAt, + createdByUserId: permissionGroup.createdBy, + createdByEmail: user.email, + }) + .from(permissionGroup) + .leftJoin(organization, eq(permissionGroup.organizationId, organization.id)) + .leftJoin(user, eq(permissionGroup.createdBy, user.id)) + + let groups + if (organizationId) { + groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId)) + } else { + groups = await baseQuery + } + + const groupsWithCounts = await Promise.all( + groups.map(async (group) => { + const [memberCount] = await db + .select({ count: count() }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, group.id)) + + return { + id: group.id, + organizationId: group.organizationId, + organizationName: group.organizationName, + name: group.name, + description: group.description, + memberCount: memberCount?.count ?? 0, + createdAt: group.createdAt.toISOString(), + createdByUserId: group.createdByUserId, + createdByEmail: group.createdByEmail, + } as AdminPermissionGroup + }) + ) + + logger.info('Admin API: Listed permission groups', { + organizationId, + count: groupsWithCounts.length, }) - .from(permissionGroup) - .leftJoin(organization, eq(permissionGroup.organizationId, organization.id)) - .leftJoin(user, eq(permissionGroup.createdBy, user.id)) - - let groups - if (organizationId) { - groups = await baseQuery.where(eq(permissionGroup.organizationId, organizationId)) - } else { - groups = await baseQuery + + return singleResponse({ + data: groupsWithCounts, + pagination: { + total: groupsWithCounts.length, + limit: groupsWithCounts.length, + offset: 0, + hasMore: false, + }, + }) + } catch (error) { + logger.error('Admin API: Failed to list permission groups', { error, organizationId }) + return internalErrorResponse('Failed to list permission groups') + } + }) +) + +export const DELETE = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const organizationId = url.searchParams.get('organizationId') + const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup' + + if (!organizationId) { + return badRequestResponse('organizationId is required') } - const groupsWithCounts = await Promise.all( - groups.map(async (group) => { - const [memberCount] = await db - .select({ count: count() }) - .from(permissionGroupMember) - .where(eq(permissionGroupMember.permissionGroupId, group.id)) - - return { - id: group.id, - organizationId: group.organizationId, - organizationName: group.organizationName, - name: group.name, - description: group.description, - memberCount: memberCount?.count ?? 0, - createdAt: group.createdAt.toISOString(), - createdByUserId: group.createdByUserId, - createdByEmail: group.createdByEmail, - } as AdminPermissionGroup + try { + const existingGroups = await db + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where(eq(permissionGroup.organizationId, organizationId)) + + if (existingGroups.length === 0) { + logger.info('Admin API: No permission groups to delete', { organizationId }) + return singleResponse({ + success: true, + deletedCount: 0, + membersRemoved: 0, + message: 'No permission groups found for the given organization', + }) + } + + const groupIds = existingGroups.map((g) => g.id) + + const [memberCountResult] = await db + .select({ count: sql`count(*)` }) + .from(permissionGroupMember) + .where(inArray(permissionGroupMember.permissionGroupId, groupIds)) + + const membersToRemove = Number(memberCountResult?.count ?? 0) + + // Members are deleted via cascade when permission groups are deleted + await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId)) + + logger.info('Admin API: Deleted permission groups', { + organizationId, + deletedCount: existingGroups.length, + membersRemoved: membersToRemove, + reason, }) - ) - - logger.info('Admin API: Listed permission groups', { - organizationId, - count: groupsWithCounts.length, - }) - - return singleResponse({ - data: groupsWithCounts, - pagination: { - total: groupsWithCounts.length, - limit: groupsWithCounts.length, - offset: 0, - hasMore: false, - }, - }) - } catch (error) { - logger.error('Admin API: Failed to list permission groups', { error, organizationId }) - return internalErrorResponse('Failed to list permission groups') - } -}) - -export const DELETE = withAdminAuth(async (request) => { - const url = new URL(request.url) - const organizationId = url.searchParams.get('organizationId') - const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup' - - if (!organizationId) { - return badRequestResponse('organizationId is required') - } - - try { - const existingGroups = await db - .select({ id: permissionGroup.id }) - .from(permissionGroup) - .where(eq(permissionGroup.organizationId, organizationId)) - - if (existingGroups.length === 0) { - logger.info('Admin API: No permission groups to delete', { organizationId }) + return singleResponse({ success: true, - deletedCount: 0, - membersRemoved: 0, - message: 'No permission groups found for the given organization', + deletedCount: existingGroups.length, + membersRemoved: membersToRemove, + reason, }) + } catch (error) { + logger.error('Admin API: Failed to delete permission groups', { error, organizationId }) + return internalErrorResponse('Failed to delete permission groups') } - - const groupIds = existingGroups.map((g) => g.id) - - const [memberCountResult] = await db - .select({ count: sql`count(*)` }) - .from(permissionGroupMember) - .where(inArray(permissionGroupMember.permissionGroupId, groupIds)) - - const membersToRemove = Number(memberCountResult?.count ?? 0) - - // Members are deleted via cascade when permission groups are deleted - await db.delete(permissionGroup).where(eq(permissionGroup.organizationId, organizationId)) - - logger.info('Admin API: Deleted permission groups', { - organizationId, - deletedCount: existingGroups.length, - membersRemoved: membersToRemove, - reason, - }) - - return singleResponse({ - success: true, - deletedCount: existingGroups.length, - membersRemoved: membersToRemove, - reason, - }) - } catch (error) { - logger.error('Admin API: Failed to delete permission groups', { error, organizationId }) - return internalErrorResponse('Failed to delete permission groups') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts index 848fbc8b31f..a84f400fb06 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts @@ -10,6 +10,7 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -24,21 +25,23 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id } = await context.params - try { - const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1) + try { + const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1) - if (!log) { - return notFoundResponse('AuditLog') - } + if (!log) { + return notFoundResponse('AuditLog') + } - logger.info(`Admin API: Retrieved audit log ${id}`) + logger.info(`Admin API: Retrieved audit log ${id}`) - return singleResponse(toAdminAuditLog(log)) - } catch (error) { - logger.error('Admin API: Failed to get audit log', { error, id }) - return internalErrorResponse('Failed to get audit log') - } -}) + return singleResponse(toAdminAuditLog(log)) + } catch (error) { + logger.error('Admin API: Failed to get audit log', { error, id }) + return internalErrorResponse('Failed to get audit log') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 895ac1ff3e2..01bad1898f8 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -22,6 +22,7 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -37,60 +38,62 @@ import { const logger = createLogger('AdminAuditLogsAPI') -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - const actionFilter = url.searchParams.get('action') - const resourceTypeFilter = url.searchParams.get('resourceType') - const resourceIdFilter = url.searchParams.get('resourceId') - const workspaceIdFilter = url.searchParams.get('workspaceId') - const actorIdFilter = url.searchParams.get('actorId') - const actorEmailFilter = url.searchParams.get('actorEmail') - const startDateFilter = url.searchParams.get('startDate') - const endDateFilter = url.searchParams.get('endDate') + const actionFilter = url.searchParams.get('action') + const resourceTypeFilter = url.searchParams.get('resourceType') + const resourceIdFilter = url.searchParams.get('resourceId') + const workspaceIdFilter = url.searchParams.get('workspaceId') + const actorIdFilter = url.searchParams.get('actorId') + const actorEmailFilter = url.searchParams.get('actorEmail') + const startDateFilter = url.searchParams.get('startDate') + const endDateFilter = url.searchParams.get('endDate') - if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) { - return badRequestResponse('Invalid startDate format. Use ISO 8601.') - } - if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) { - return badRequestResponse('Invalid endDate format. Use ISO 8601.') - } + if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) { + return badRequestResponse('Invalid startDate format. Use ISO 8601.') + } + if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) { + return badRequestResponse('Invalid endDate format. Use ISO 8601.') + } - try { - const conditions: SQL[] = [] + try { + const conditions: SQL[] = [] - if (actionFilter) conditions.push(eq(auditLog.action, actionFilter)) - if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter)) - if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter)) - if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter)) - if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter)) - if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter)) - if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter))) - if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter))) + if (actionFilter) conditions.push(eq(auditLog.action, actionFilter)) + if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter)) + if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter)) + if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter)) + if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter)) + if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter)) + if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter))) + if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter))) - const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const whereClause = conditions.length > 0 ? and(...conditions) : undefined - const [countResult, logs] = await Promise.all([ - db.select({ total: count() }).from(auditLog).where(whereClause), - db - .select() - .from(auditLog) - .where(whereClause) - .orderBy(desc(auditLog.createdAt)) - .limit(limit) - .offset(offset), - ]) + const [countResult, logs] = await Promise.all([ + db.select({ total: count() }).from(auditLog).where(whereClause), + db + .select() + .from(auditLog) + .where(whereClause) + .orderBy(desc(auditLog.createdAt)) + .limit(limit) + .offset(offset), + ]) - const total = countResult[0].total - const data: AdminAuditLog[] = logs.map(toAdminAuditLog) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminAuditLog[] = logs.map(toAdminAuditLog) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`) + logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list audit logs', { error }) - return internalErrorResponse('Failed to list audit logs') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list audit logs', { error }) + return internalErrorResponse('Failed to list audit logs') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts index feaec3b95d9..c7519d84bea 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -36,6 +36,7 @@ import { getEffectiveSeats, } from '@/lib/billing/subscriptions/utils' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -46,175 +47,177 @@ import { const logger = createLogger('AdminCreditsAPI') -export const POST = withAdminAuth(async (request) => { - try { - const body = await request.json() - const { userId, email, amount, reason } = body +export const POST = withRouteHandler( + withAdminAuth(async (request) => { + try { + const body = await request.json() + const { userId, email, amount, reason } = body - if (!userId && !email) { - return badRequestResponse('Either userId or email is required') - } - - if (userId && typeof userId !== 'string') { - return badRequestResponse('userId must be a string') - } - - if (email && typeof email !== 'string') { - return badRequestResponse('email must be a string') - } - - if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { - return badRequestResponse('amount must be a positive number') - } - - let resolvedUserId: string - let userEmail: string | null = null - - if (userId) { - const [userData] = await db - .select({ id: user.id, email: user.email }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) - - if (!userData) { - return notFoundResponse('User') + if (!userId && !email) { + return badRequestResponse('Either userId or email is required') } - resolvedUserId = userData.id - userEmail = userData.email - } else { - const normalizedEmail = email.toLowerCase().trim() - const [userData] = await db - .select({ id: user.id, email: user.email }) - .from(user) - .where(eq(user.email, normalizedEmail)) - .limit(1) - - if (!userData) { - return notFoundResponse('User with email') - } - resolvedUserId = userData.id - userEmail = userData.email - } - const userSubscription = await getHighestPrioritySubscription(resolvedUserId) + if (userId && typeof userId !== 'string') { + return badRequestResponse('userId must be a string') + } - if (!userSubscription || !isPaid(userSubscription.plan)) { - return badRequestResponse( - 'User must have an active Pro, Team, or Enterprise subscription to receive credits' - ) - } + if (email && typeof email !== 'string') { + return badRequestResponse('email must be a string') + } - let entityType: 'user' | 'organization' - let entityId: string - const plan = userSubscription.plan - let seats: number | null = null + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { + return badRequestResponse('amount must be a positive number') + } - if (isOrgPlan(plan)) { - entityType = 'organization' - entityId = userSubscription.referenceId + let resolvedUserId: string + let userEmail: string | null = null + + if (userId) { + const [userData] = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + if (!userData) { + return notFoundResponse('User') + } + resolvedUserId = userData.id + userEmail = userData.email + } else { + const normalizedEmail = email.toLowerCase().trim() + const [userData] = await db + .select({ id: user.id, email: user.email }) + .from(user) + .where(eq(user.email, normalizedEmail)) + .limit(1) + + if (!userData) { + return notFoundResponse('User with email') + } + resolvedUserId = userData.id + userEmail = userData.email + } - const [orgExists] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, entityId)) - .limit(1) + const userSubscription = await getHighestPrioritySubscription(resolvedUserId) - if (!orgExists) { - return notFoundResponse('Organization') + if (!userSubscription || !isPaid(userSubscription.plan)) { + return badRequestResponse( + 'User must have an active Pro, Team, or Enterprise subscription to receive credits' + ) } - const [subData] = await db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, entityId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + let entityType: 'user' | 'organization' + let entityId: string + const plan = userSubscription.plan + let seats: number | null = null + + if (isOrgPlan(plan)) { + entityType = 'organization' + entityId = userSubscription.referenceId + + const [orgExists] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + + if (!orgExists) { + return notFoundResponse('Organization') + } + + const [subData] = await db + .select() + .from(subscription) + .where( + and( + eq(subscription.referenceId, entityId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) ) - ) - .limit(1) - - seats = getEffectiveSeats(subData) - } else { - entityType = 'user' - entityId = resolvedUserId - - const [existingStats] = await db - .select({ id: userStats.id }) - .from(userStats) - .where(eq(userStats.userId, entityId)) - .limit(1) - - if (!existingStats) { - await db.insert(userStats).values({ - id: generateShortId(), - userId: entityId, - }) + .limit(1) + + seats = getEffectiveSeats(subData) + } else { + entityType = 'user' + entityId = resolvedUserId + + const [existingStats] = await db + .select({ id: userStats.id }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + + if (!existingStats) { + await db.insert(userStats).values({ + id: generateShortId(), + userId: entityId, + }) + } } - } - await addCredits(entityType, entityId, amount) - - let newCreditBalance: number - if (entityType === 'organization') { - const [orgData] = await db - .select({ creditBalance: organization.creditBalance }) - .from(organization) - .where(eq(organization.id, entityId)) - .limit(1) - newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0') - } else { - const [stats] = await db - .select({ creditBalance: userStats.creditBalance }) - .from(userStats) - .where(eq(userStats.userId, entityId)) - .limit(1) - newCreditBalance = Number.parseFloat(stats?.creditBalance || '0') - } + await addCredits(entityType, entityId, amount) + + let newCreditBalance: number + if (entityType === 'organization') { + const [orgData] = await db + .select({ creditBalance: organization.creditBalance }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0') + } else { + const [stats] = await db + .select({ creditBalance: userStats.creditBalance }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + newCreditBalance = Number.parseFloat(stats?.creditBalance || '0') + } - await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance) - - let newUsageLimit: number - if (entityType === 'organization') { - const [orgData] = await db - .select({ orgUsageLimit: organization.orgUsageLimit }) - .from(organization) - .where(eq(organization.id, entityId)) - .limit(1) - newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0') - } else { - const [stats] = await db - .select({ currentUsageLimit: userStats.currentUsageLimit }) - .from(userStats) - .where(eq(userStats.userId, entityId)) - .limit(1) - newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0') - } + await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance) + + let newUsageLimit: number + if (entityType === 'organization') { + const [orgData] = await db + .select({ orgUsageLimit: organization.orgUsageLimit }) + .from(organization) + .where(eq(organization.id, entityId)) + .limit(1) + newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0') + } else { + const [stats] = await db + .select({ currentUsageLimit: userStats.currentUsageLimit }) + .from(userStats) + .where(eq(userStats.userId, entityId)) + .limit(1) + newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0') + } - logger.info('Admin API: Issued credits', { - resolvedUserId, - userEmail, - entityType, - entityId, - amount, - newCreditBalance, - newUsageLimit, - reason: reason || 'No reason provided', - }) - - return singleResponse({ - success: true, - userId: resolvedUserId, - userEmail, - entityType, - entityId, - amount, - newCreditBalance, - newUsageLimit, - }) - } catch (error) { - logger.error('Admin API: Failed to issue credits', { error }) - return internalErrorResponse('Failed to issue credits') - } -}) + logger.info('Admin API: Issued credits', { + resolvedUserId, + userEmail, + entityType, + entityId, + amount, + newCreditBalance, + newUsageLimit, + reason: reason || 'No reason provided', + }) + + return singleResponse({ + success: true, + userId: resolvedUserId, + userEmail, + entityType, + entityId, + amount, + newCreditBalance, + newUsageLimit, + }) + } catch (error) { + logger.error('Admin API: Failed to issue credits', { error }) + return internalErrorResponse('Failed to issue credits') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 101d96896e3..8bee76f243c 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -16,6 +16,7 @@ import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -94,154 +95,156 @@ function collectSubfolders( return subfolders } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: folderId } = await context.params - const url = new URL(request.url) - const format = url.searchParams.get('format') || 'zip' - - try { - const [folderData] = await db - .select({ - id: workflowFolder.id, - name: workflowFolder.name, - workspaceId: workflowFolder.workspaceId, - }) - .from(workflowFolder) - .where(eq(workflowFolder.id, folderId)) - .limit(1) - - if (!folderData) { - return notFoundResponse('Folder') - } - - const allWorkflows = await db - .select({ id: workflow.id, folderId: workflow.folderId }) - .from(workflow) - .where(eq(workflow.workspaceId, folderData.workspaceId)) +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: folderId } = await context.params + const url = new URL(request.url) + const format = url.searchParams.get('format') || 'zip' + + try { + const [folderData] = await db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + workspaceId: workflowFolder.workspaceId, + }) + .from(workflowFolder) + .where(eq(workflowFolder.id, folderId)) + .limit(1) - const allFolders = await db - .select({ - id: workflowFolder.id, - name: workflowFolder.name, - parentId: workflowFolder.parentId, - }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, folderData.workspaceId)) - - const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders) - const subfolders = collectSubfolders(folderId, allFolders) - - const workflowExports: Array<{ - workflow: { - id: string - name: string - description: string | null - color: string | null - folderId: string | null + if (!folderData) { + return notFoundResponse('Folder') } - state: WorkflowExportState - }> = [] - - for (const collectedWf of workflowsInFolder) { - try { - const [wfData] = await db - .select() - .from(workflow) - .where(eq(workflow.id, collectedWf.id)) - .limit(1) - - if (!wfData) { - logger.warn(`Skipping workflow ${collectedWf.id} - not found`) - continue - } - const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id) + const allWorkflows = await db + .select({ id: workflow.id, folderId: workflow.folderId }) + .from(workflow) + .where(eq(workflow.workspaceId, folderData.workspaceId)) - if (!normalizedData) { - logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`) - continue + const allFolders = await db + .select({ + id: workflowFolder.id, + name: workflowFolder.name, + parentId: workflowFolder.parentId, + }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, folderData.workspaceId)) + + const workflowsInFolder = collectWorkflowsInFolder(folderId, allWorkflows, allFolders) + const subfolders = collectSubfolders(folderId, allFolders) + + const workflowExports: Array<{ + workflow: { + id: string + name: string + description: string | null + color: string | null + folderId: string | null } + state: WorkflowExportState + }> = [] + + for (const collectedWf of workflowsInFolder) { + try { + const [wfData] = await db + .select() + .from(workflow) + .where(eq(workflow.id, collectedWf.id)) + .limit(1) + + if (!wfData) { + logger.warn(`Skipping workflow ${collectedWf.id} - not found`) + continue + } + + const normalizedData = await loadWorkflowFromNormalizedTables(collectedWf.id) + + if (!normalizedData) { + logger.warn(`Skipping workflow ${collectedWf.id} - no normalized data found`) + continue + } + + const variables = parseWorkflowVariables(wfData.variables) + + const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId + + const state: WorkflowExportState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: wfData.name, + description: wfData.description ?? undefined, + color: wfData.color, + exportedAt: new Date().toISOString(), + }, + variables, + } + + workflowExports.push({ + workflow: { + id: wfData.id, + name: wfData.name, + description: wfData.description, + color: wfData.color, + folderId: remappedFolderId, + }, + state, + }) + } catch (error) { + logger.error(`Failed to load workflow ${collectedWf.id}:`, { error }) + } + } - const variables = parseWorkflowVariables(wfData.variables) - - const remappedFolderId = collectedWf.folderId === folderId ? null : collectedWf.folderId - - const state: WorkflowExportState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: wfData.name, - description: wfData.description ?? undefined, - color: wfData.color, - exportedAt: new Date().toISOString(), + logger.info( + `Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders` + ) + + if (format === 'json') { + const exportPayload = { + version: '1.0', + exportedAt: new Date().toISOString(), + folder: { + id: folderData.id, + name: folderData.name, }, - variables, + workflows: workflowExports, + folders: subfolders, } - workflowExports.push({ - workflow: { - id: wfData.id, - name: wfData.name, - description: wfData.description, - color: wfData.color, - folderId: remappedFolderId, - }, - state, - }) - } catch (error) { - logger.error(`Failed to load workflow ${collectedWf.id}:`, { error }) + return singleResponse(exportPayload) } - } - logger.info( - `Admin API: Exporting folder ${folderId} with ${workflowExports.length} workflows and ${subfolders.length} subfolders` - ) - - if (format === 'json') { - const exportPayload = { - version: '1.0', - exportedAt: new Date().toISOString(), - folder: { - id: folderData.id, - name: folderData.name, + const zipWorkflows = workflowExports.map((wf) => ({ + workflow: { + id: wf.workflow.id, + name: wf.workflow.name, + description: wf.workflow.description ?? undefined, + color: wf.workflow.color ?? undefined, + folderId: wf.workflow.folderId, }, - workflows: workflowExports, - folders: subfolders, - } - - return singleResponse(exportPayload) + state: wf.state, + variables: wf.state.variables, + })) + + const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders) + const arrayBuffer = await zipBlob.arrayBuffer() + + const sanitizedName = sanitizePathSegment(folderData.name) + const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` + + return new NextResponse(arrayBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': arrayBuffer.byteLength.toString(), + }, + }) + } catch (error) { + logger.error('Admin API: Failed to export folder', { error, folderId }) + return internalErrorResponse('Failed to export folder') } - - const zipWorkflows = workflowExports.map((wf) => ({ - workflow: { - id: wf.workflow.id, - name: wf.workflow.name, - description: wf.workflow.description ?? undefined, - color: wf.workflow.color ?? undefined, - folderId: wf.workflow.folderId, - }, - state: wf.state, - variables: wf.state.variables, - })) - - const zipBlob = await exportFolderToZip(folderData.name, zipWorkflows, subfolders) - const arrayBuffer = await zipBlob.arrayBuffer() - - const sanitizedName = sanitizePathSegment(folderData.name) - const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` - - return new NextResponse(arrayBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': arrayBuffer.byteLength.toString(), - }, - }) - } catch (error) { - logger.error('Admin API: Failed to export folder', { error, folderId }) - return internalErrorResponse('Failed to export folder') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts index b5636998308..c03b4ffa2cd 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/billing/route.ts @@ -21,6 +21,7 @@ import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { getOrganizationBillingData } from '@/lib/billing/core/organization' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -36,137 +37,144 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (_, context) => { - const { id: organizationId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: organizationId } = await context.params + + try { + if (!isBillingEnabled) { + const [[orgData], [memberCount]] = await Promise.all([ + db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), + db + .select({ count: count() }) + .from(member) + .where(eq(member.organizationId, organizationId)), + ]) + + if (!orgData) { + return notFoundResponse('Organization') + } + + const data: AdminOrganizationBillingSummary = { + organizationId: orgData.id, + organizationName: orgData.name, + subscriptionPlan: 'none', + subscriptionStatus: 'none', + totalSeats: Number.MAX_SAFE_INTEGER, + usedSeats: memberCount?.count || 0, + availableSeats: Number.MAX_SAFE_INTEGER, + totalCurrentUsage: 0, + totalUsageLimit: Number.MAX_SAFE_INTEGER, + minimumBillingAmount: 0, + averageUsagePerMember: 0, + usagePercentage: 0, + billingPeriodStart: null, + billingPeriodEnd: null, + membersOverLimit: 0, + membersNearLimit: 0, + } + + logger.info( + `Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)` + ) + + return singleResponse(data) + } - try { - if (!isBillingEnabled) { - const [[orgData], [memberCount]] = await Promise.all([ - db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), - db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)), - ]) + const billingData = await getOrganizationBillingData(organizationId) - if (!orgData) { - return notFoundResponse('Organization') + if (!billingData) { + return notFoundResponse('Organization or subscription') } + const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length + const membersNearLimit = billingData.members.filter( + (m) => !m.isOverLimit && m.percentUsed >= 80 + ).length + const usagePercentage = + billingData.totalUsageLimit > 0 + ? Math.round((billingData.totalCurrentUsage / billingData.totalUsageLimit) * 10000) / 100 + : 0 + const data: AdminOrganizationBillingSummary = { - organizationId: orgData.id, - organizationName: orgData.name, - subscriptionPlan: 'none', - subscriptionStatus: 'none', - totalSeats: Number.MAX_SAFE_INTEGER, - usedSeats: memberCount?.count || 0, - availableSeats: Number.MAX_SAFE_INTEGER, - totalCurrentUsage: 0, - totalUsageLimit: Number.MAX_SAFE_INTEGER, - minimumBillingAmount: 0, - averageUsagePerMember: 0, - usagePercentage: 0, - billingPeriodStart: null, - billingPeriodEnd: null, - membersOverLimit: 0, - membersNearLimit: 0, + organizationId: billingData.organizationId, + organizationName: billingData.organizationName, + subscriptionPlan: billingData.subscriptionPlan, + subscriptionStatus: billingData.subscriptionStatus, + totalSeats: billingData.totalSeats, + usedSeats: billingData.usedSeats, + availableSeats: billingData.totalSeats - billingData.usedSeats, + totalCurrentUsage: billingData.totalCurrentUsage, + totalUsageLimit: billingData.totalUsageLimit, + minimumBillingAmount: billingData.minimumBillingAmount, + averageUsagePerMember: billingData.averageUsagePerMember, + usagePercentage, + billingPeriodStart: billingData.billingPeriodStart?.toISOString() ?? null, + billingPeriodEnd: billingData.billingPeriodEnd?.toISOString() ?? null, + membersOverLimit, + membersNearLimit, } - logger.info( - `Admin API: Retrieved billing summary for organization ${organizationId} (billing disabled)` - ) + logger.info(`Admin API: Retrieved billing summary for organization ${organizationId}`) return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get organization billing', { error, organizationId }) + return internalErrorResponse('Failed to get organization billing') } + }) +) - const billingData = await getOrganizationBillingData(organizationId) - - if (!billingData) { - return notFoundResponse('Organization or subscription') - } - - const membersOverLimit = billingData.members.filter((m) => m.isOverLimit).length - const membersNearLimit = billingData.members.filter( - (m) => !m.isOverLimit && m.percentUsed >= 80 - ).length - const usagePercentage = - billingData.totalUsageLimit > 0 - ? Math.round((billingData.totalCurrentUsage / billingData.totalUsageLimit) * 10000) / 100 - : 0 - - const data: AdminOrganizationBillingSummary = { - organizationId: billingData.organizationId, - organizationName: billingData.organizationName, - subscriptionPlan: billingData.subscriptionPlan, - subscriptionStatus: billingData.subscriptionStatus, - totalSeats: billingData.totalSeats, - usedSeats: billingData.usedSeats, - availableSeats: billingData.totalSeats - billingData.usedSeats, - totalCurrentUsage: billingData.totalCurrentUsage, - totalUsageLimit: billingData.totalUsageLimit, - minimumBillingAmount: billingData.minimumBillingAmount, - averageUsagePerMember: billingData.averageUsagePerMember, - usagePercentage, - billingPeriodStart: billingData.billingPeriodStart?.toISOString() ?? null, - billingPeriodEnd: billingData.billingPeriodEnd?.toISOString() ?? null, - membersOverLimit, - membersNearLimit, - } - - logger.info(`Admin API: Retrieved billing summary for organization ${organizationId}`) - - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get organization billing', { error, organizationId }) - return internalErrorResponse('Failed to get organization billing') - } -}) - -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params - - try { - const body = await request.json() +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId } = await context.params - const [orgData] = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + try { + const body = await request.json() - if (!orgData) { - return notFoundResponse('Organization') - } - - if (body.orgUsageLimit !== undefined) { - let newLimit: string | null = null + const [orgData] = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (body.orgUsageLimit === null) { - newLimit = null - } else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) { - newLimit = body.orgUsageLimit.toFixed(2) - } else { - return badRequestResponse('orgUsageLimit must be a non-negative number or null') + if (!orgData) { + return notFoundResponse('Organization') } - await db - .update(organization) - .set({ - orgUsageLimit: newLimit, - updatedAt: new Date(), + if (body.orgUsageLimit !== undefined) { + let newLimit: string | null = null + + if (body.orgUsageLimit === null) { + newLimit = null + } else if (typeof body.orgUsageLimit === 'number' && body.orgUsageLimit >= 0) { + newLimit = body.orgUsageLimit.toFixed(2) + } else { + return badRequestResponse('orgUsageLimit must be a non-negative number or null') + } + + await db + .update(organization) + .set({ + orgUsageLimit: newLimit, + updatedAt: new Date(), + }) + .where(eq(organization.id, organizationId)) + + logger.info(`Admin API: Updated usage limit for organization ${organizationId}`, { + newLimit, }) - .where(eq(organization.id, organizationId)) - logger.info(`Admin API: Updated usage limit for organization ${organizationId}`, { - newLimit, - }) + return singleResponse({ + success: true, + orgUsageLimit: newLimit, + }) + } - return singleResponse({ - success: true, - orgUsageLimit: newLimit, - }) + return badRequestResponse('No valid fields to update') + } catch (error) { + logger.error('Admin API: Failed to update organization billing', { error, organizationId }) + return internalErrorResponse('Failed to update organization billing') } - - return badRequestResponse('No valid fields to update') - } catch (error) { - logger.error('Admin API: Failed to update organization billing', { error, organizationId }) - return internalErrorResponse('Failed to update organization billing') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts index d3691a6720b..515c9617d45 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/[memberId]/route.ts @@ -31,6 +31,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -47,206 +48,213 @@ interface RouteParams { memberId: string } -export const GET = withAdminAuthParams(async (_, context) => { - const { id: organizationId, memberId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: organizationId, memberId } = await context.params - try { - const [orgData] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + try { + const [orgData] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!orgData) { - return notFoundResponse('Organization') - } + if (!orgData) { + return notFoundResponse('Organization') + } - const [memberData] = await db - .select({ - id: member.id, - userId: member.userId, - organizationId: member.organizationId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - currentPeriodCost: userStats.currentPeriodCost, - currentUsageLimit: userStats.currentUsageLimit, - lastActive: userStats.lastActive, - billingBlocked: userStats.billingBlocked, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId))) - .limit(1) - - if (!memberData) { - return notFoundResponse('Member') - } + const [memberData] = await db + .select({ + id: member.id, + userId: member.userId, + organizationId: member.organizationId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, + currentPeriodCost: userStats.currentPeriodCost, + currentUsageLimit: userStats.currentUsageLimit, + lastActive: userStats.lastActive, + billingBlocked: userStats.billingBlocked, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId))) + .limit(1) + + if (!memberData) { + return notFoundResponse('Member') + } + + const data: AdminMemberDetail = { + id: memberData.id, + userId: memberData.userId, + organizationId: memberData.organizationId, + role: memberData.role, + createdAt: memberData.createdAt.toISOString(), + userName: memberData.userName, + userEmail: memberData.userEmail, + currentPeriodCost: memberData.currentPeriodCost ?? '0', + currentUsageLimit: memberData.currentUsageLimit, + lastActive: memberData.lastActive?.toISOString() ?? null, + billingBlocked: memberData.billingBlocked ?? false, + } + + logger.info(`Admin API: Retrieved member ${memberId} from organization ${organizationId}`) - const data: AdminMemberDetail = { - id: memberData.id, - userId: memberData.userId, - organizationId: memberData.organizationId, - role: memberData.role, - createdAt: memberData.createdAt.toISOString(), - userName: memberData.userName, - userEmail: memberData.userEmail, - currentPeriodCost: memberData.currentPeriodCost ?? '0', - currentUsageLimit: memberData.currentUsageLimit, - lastActive: memberData.lastActive?.toISOString() ?? null, - billingBlocked: memberData.billingBlocked ?? false, + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get member', { error, organizationId, memberId }) + return internalErrorResponse('Failed to get member') } + }) +) - logger.info(`Admin API: Retrieved member ${memberId} from organization ${organizationId}`) +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId, memberId } = await context.params - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get member', { error, organizationId, memberId }) - return internalErrorResponse('Failed to get member') - } -}) + try { + const body = await request.json() -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: organizationId, memberId } = await context.params + if (!body.role || !['admin', 'member'].includes(body.role)) { + return badRequestResponse('role must be "admin" or "member"') + } - try { - const body = await request.json() + const [orgData] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!body.role || !['admin', 'member'].includes(body.role)) { - return badRequestResponse('role must be "admin" or "member"') - } + if (!orgData) { + return notFoundResponse('Organization') + } - const [orgData] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const [existingMember] = await db + .select({ + id: member.id, + userId: member.userId, + role: member.role, + }) + .from(member) + .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId))) + .limit(1) + + if (!existingMember) { + return notFoundResponse('Member') + } - if (!orgData) { - return notFoundResponse('Organization') - } + if (existingMember.role === 'owner') { + return badRequestResponse('Cannot change owner role') + } - const [existingMember] = await db - .select({ - id: member.id, - userId: member.userId, - role: member.role, - }) - .from(member) - .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId))) - .limit(1) + const [updated] = await db + .update(member) + .set({ role: body.role }) + .where(eq(member.id, memberId)) + .returning() + + const [userData] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, updated.userId)) + .limit(1) + + const data: AdminMember = { + id: updated.id, + userId: updated.userId, + organizationId: updated.organizationId, + role: updated.role, + createdAt: updated.createdAt.toISOString(), + userName: userData?.name ?? '', + userEmail: userData?.email ?? '', + } - if (!existingMember) { - return notFoundResponse('Member') - } + logger.info(`Admin API: Updated member ${memberId} role to ${body.role}`, { + organizationId, + previousRole: existingMember.role, + }) - if (existingMember.role === 'owner') { - return badRequestResponse('Cannot change owner role') + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to update member', { error, organizationId, memberId }) + return internalErrorResponse('Failed to update member') } + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId, memberId } = await context.params + const url = new URL(request.url) + const skipBillingLogic = + !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true' + + try { + const [orgData] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (!orgData) { + return notFoundResponse('Organization') + } - const [updated] = await db - .update(member) - .set({ role: body.role }) - .where(eq(member.id, memberId)) - .returning() - - const [userData] = await db - .select({ name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, updated.userId)) - .limit(1) - - const data: AdminMember = { - id: updated.id, - userId: updated.userId, - organizationId: updated.organizationId, - role: updated.role, - createdAt: updated.createdAt.toISOString(), - userName: userData?.name ?? '', - userEmail: userData?.email ?? '', - } + const [existingMember] = await db + .select({ + id: member.id, + userId: member.userId, + role: member.role, + }) + .from(member) + .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId))) + .limit(1) + + if (!existingMember) { + return notFoundResponse('Member') + } - logger.info(`Admin API: Updated member ${memberId} role to ${body.role}`, { - organizationId, - previousRole: existingMember.role, - }) - - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to update member', { error, organizationId, memberId }) - return internalErrorResponse('Failed to update member') - } -}) - -export const DELETE = withAdminAuthParams(async (request, context) => { - const { id: organizationId, memberId } = await context.params - const url = new URL(request.url) - const skipBillingLogic = !isBillingEnabled || url.searchParams.get('skipBillingLogic') === 'true' - - try { - const [orgData] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - if (!orgData) { - return notFoundResponse('Organization') - } + const userId = existingMember.userId - const [existingMember] = await db - .select({ - id: member.id, - userId: member.userId, - role: member.role, + const result = await removeUserFromOrganization({ + userId, + organizationId, + memberId, + skipBillingLogic, }) - .from(member) - .where(and(eq(member.id, memberId), eq(member.organizationId, organizationId))) - .limit(1) - - if (!existingMember) { - return notFoundResponse('Member') - } - const userId = existingMember.userId + if (!result.success) { + if (result.error === 'Cannot remove organization owner') { + return badRequestResponse(result.error) + } + if (result.error === 'Member not found') { + return notFoundResponse('Member') + } + return internalErrorResponse(result.error || 'Failed to remove member') + } - const result = await removeUserFromOrganization({ - userId, - organizationId, - memberId, - skipBillingLogic, - }) + logger.info(`Admin API: Removed member ${memberId} from organization ${organizationId}`, { + userId, + billingActions: result.billingActions, + }) - if (!result.success) { - if (result.error === 'Cannot remove organization owner') { - return badRequestResponse(result.error) - } - if (result.error === 'Member not found') { - return notFoundResponse('Member') - } - return internalErrorResponse(result.error || 'Failed to remove member') + return singleResponse({ + success: true, + memberId, + userId, + billingActions: { + usageCaptured: result.billingActions.usageCaptured, + proRestored: result.billingActions.proRestored, + usageRestored: result.billingActions.usageRestored, + skipBillingLogic, + }, + }) + } catch (error) { + logger.error('Admin API: Failed to remove member', { error, organizationId, memberId }) + return internalErrorResponse('Failed to remove member') } - - logger.info(`Admin API: Removed member ${memberId} from organization ${organizationId}`, { - userId, - billingActions: result.billingActions, - }) - - return singleResponse({ - success: true, - memberId, - userId, - billingActions: { - usageCaptured: result.billingActions.usageCaptured, - proRestored: result.billingActions.proRestored, - usageRestored: result.billingActions.usageRestored, - skipBillingLogic, - }, - }) - } catch (error) { - logger.error('Admin API: Failed to remove member', { error, organizationId, memberId }) - return internalErrorResponse('Failed to remove member') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts index cc9cee63206..7daef46ab70 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/members/route.ts @@ -35,6 +35,7 @@ import { count, eq } from 'drizzle-orm' import { addUserToOrganization } from '@/lib/billing/organizations/membership' import { requireStripeClient } from '@/lib/billing/stripe-client' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -56,140 +57,159 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId } = await context.params + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const [orgData] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + try { + const [orgData] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!orgData) { - return notFoundResponse('Organization') - } + if (!orgData) { + return notFoundResponse('Organization') + } - const [countResult, membersData] = await Promise.all([ - db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)), - db - .select({ - id: member.id, - userId: member.userId, - organizationId: member.organizationId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - currentPeriodCost: userStats.currentPeriodCost, - currentUsageLimit: userStats.currentUsageLimit, - lastActive: userStats.lastActive, - billingBlocked: userStats.billingBlocked, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(userStats, eq(member.userId, userStats.userId)) - .where(eq(member.organizationId, organizationId)) - .orderBy(member.createdAt) - .limit(limit) - .offset(offset), - ]) + const [countResult, membersData] = await Promise.all([ + db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)), + db + .select({ + id: member.id, + userId: member.userId, + organizationId: member.organizationId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, + currentPeriodCost: userStats.currentPeriodCost, + currentUsageLimit: userStats.currentUsageLimit, + lastActive: userStats.lastActive, + billingBlocked: userStats.billingBlocked, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .leftJoin(userStats, eq(member.userId, userStats.userId)) + .where(eq(member.organizationId, organizationId)) + .orderBy(member.createdAt) + .limit(limit) + .offset(offset), + ]) - const total = countResult[0].count - const data: AdminMemberDetail[] = membersData.map((m) => ({ - id: m.id, - userId: m.userId, - organizationId: m.organizationId, - role: m.role, - createdAt: m.createdAt.toISOString(), - userName: m.userName, - userEmail: m.userEmail, - currentPeriodCost: m.currentPeriodCost ?? '0', - currentUsageLimit: m.currentUsageLimit, - lastActive: m.lastActive?.toISOString() ?? null, - billingBlocked: m.billingBlocked ?? false, - })) + const total = countResult[0].count + const data: AdminMemberDetail[] = membersData.map((m) => ({ + id: m.id, + userId: m.userId, + organizationId: m.organizationId, + role: m.role, + createdAt: m.createdAt.toISOString(), + userName: m.userName, + userEmail: m.userEmail, + currentPeriodCost: m.currentPeriodCost ?? '0', + currentUsageLimit: m.currentUsageLimit, + lastActive: m.lastActive?.toISOString() ?? null, + billingBlocked: m.billingBlocked ?? false, + })) - const pagination = createPaginationMeta(total, limit, offset) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} members for organization ${organizationId}`) + logger.info(`Admin API: Listed ${data.length} members for organization ${organizationId}`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list organization members', { error, organizationId }) - return internalErrorResponse('Failed to list organization members') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list organization members', { error, organizationId }) + return internalErrorResponse('Failed to list organization members') + } + }) +) -export const POST = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params +export const POST = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId } = await context.params - try { - const body = await request.json() + try { + const body = await request.json() - if (!body.userId || typeof body.userId !== 'string') { - return badRequestResponse('userId is required') - } + if (!body.userId || typeof body.userId !== 'string') { + return badRequestResponse('userId is required') + } - if (!body.role || !['admin', 'member'].includes(body.role)) { - return badRequestResponse('role must be "admin" or "member"') - } + if (!body.role || !['admin', 'member'].includes(body.role)) { + return badRequestResponse('role must be "admin" or "member"') + } - const [orgData] = await db - .select({ id: organization.id, name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const [orgData] = await db + .select({ id: organization.id, name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!orgData) { - return notFoundResponse('Organization') - } + if (!orgData) { + return notFoundResponse('Organization') + } - const [userData] = await db - .select({ id: user.id, name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, body.userId)) - .limit(1) + const [userData] = await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, body.userId)) + .limit(1) - if (!userData) { - return notFoundResponse('User') - } + if (!userData) { + return notFoundResponse('User') + } - const [existingMember] = await db - .select({ - id: member.id, - role: member.role, - createdAt: member.createdAt, - organizationId: member.organizationId, - }) - .from(member) - .where(eq(member.userId, body.userId)) - .limit(1) + const [existingMember] = await db + .select({ + id: member.id, + role: member.role, + createdAt: member.createdAt, + organizationId: member.organizationId, + }) + .from(member) + .where(eq(member.userId, body.userId)) + .limit(1) - if (existingMember) { - if (existingMember.organizationId === organizationId) { - if (existingMember.role !== body.role) { - await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id)) + if (existingMember) { + if (existingMember.organizationId === organizationId) { + if (existingMember.role !== body.role) { + await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id)) - logger.info( - `Admin API: Updated user ${body.userId} role in organization ${organizationId}`, - { - previousRole: existingMember.role, - newRole: body.role, - } - ) + logger.info( + `Admin API: Updated user ${body.userId} role in organization ${organizationId}`, + { + previousRole: existingMember.role, + newRole: body.role, + } + ) + + return singleResponse({ + id: existingMember.id, + userId: body.userId, + organizationId, + role: body.role, + createdAt: existingMember.createdAt.toISOString(), + userName: userData.name, + userEmail: userData.email, + action: 'updated' as const, + billingActions: { + proUsageSnapshotted: false, + proCancelledAtPeriodEnd: false, + }, + }) + } return singleResponse({ id: existingMember.id, userId: body.userId, organizationId, - role: body.role, + role: existingMember.role, createdAt: existingMember.createdAt.toISOString(), userName: userData.name, userEmail: userData.email, - action: 'updated' as const, + action: 'already_member' as const, billingActions: { proUsageSnapshotted: false, proCancelledAtPeriodEnd: false, @@ -197,86 +217,73 @@ export const POST = withAdminAuthParams(async (request, context) => }) } - return singleResponse({ - id: existingMember.id, - userId: body.userId, - organizationId, - role: existingMember.role, - createdAt: existingMember.createdAt.toISOString(), - userName: userData.name, - userEmail: userData.email, - action: 'already_member' as const, - billingActions: { - proUsageSnapshotted: false, - proCancelledAtPeriodEnd: false, - }, - }) + return badRequestResponse( + `User is already a member of another organization. Users can only belong to one organization at a time.` + ) } - return badRequestResponse( - `User is already a member of another organization. Users can only belong to one organization at a time.` - ) - } - - const result = await addUserToOrganization({ - userId: body.userId, - organizationId, - role: body.role, - skipBillingLogic: !isBillingEnabled, - }) + const result = await addUserToOrganization({ + userId: body.userId, + organizationId, + role: body.role, + skipBillingLogic: !isBillingEnabled, + }) - if (!result.success) { - return badRequestResponse(result.error || 'Failed to add member') - } + if (!result.success) { + return badRequestResponse(result.error || 'Failed to add member') + } - if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { - try { - const stripe = requireStripeClient() - await stripe.subscriptions.update( - result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - { cancel_at_period_end: true } - ) - logger.info('Admin API: Synced Pro cancellation with Stripe', { - userId: body.userId, - subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, - stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - }) - } catch (stripeError) { - logger.error('Admin API: Failed to sync Pro cancellation with Stripe', { - userId: body.userId, - subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, - stripeSubscriptionId: result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, - error: stripeError, - }) + if (isBillingEnabled && result.billingActions.proSubscriptionToCancel?.stripeSubscriptionId) { + try { + const stripe = requireStripeClient() + await stripe.subscriptions.update( + result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + { cancel_at_period_end: true } + ) + logger.info('Admin API: Synced Pro cancellation with Stripe', { + userId: body.userId, + subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, + stripeSubscriptionId: + result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + }) + } catch (stripeError) { + logger.error('Admin API: Failed to sync Pro cancellation with Stripe', { + userId: body.userId, + subscriptionId: result.billingActions.proSubscriptionToCancel.subscriptionId, + stripeSubscriptionId: + result.billingActions.proSubscriptionToCancel.stripeSubscriptionId, + error: stripeError, + }) + } } - } - const data: AdminMember = { - id: result.memberId!, - userId: body.userId, - organizationId, - role: body.role, - createdAt: new Date().toISOString(), - userName: userData.name, - userEmail: userData.email, - } + const data: AdminMember = { + id: result.memberId!, + userId: body.userId, + organizationId, + role: body.role, + createdAt: new Date().toISOString(), + userName: userData.name, + userEmail: userData.email, + } - logger.info(`Admin API: Added user ${body.userId} to organization ${organizationId}`, { - role: body.role, - memberId: result.memberId, - billingActions: result.billingActions, - }) + logger.info(`Admin API: Added user ${body.userId} to organization ${organizationId}`, { + role: body.role, + memberId: result.memberId, + billingActions: result.billingActions, + }) - return singleResponse({ - ...data, - action: 'created' as const, - billingActions: { - proUsageSnapshotted: result.billingActions.proUsageSnapshotted, - proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd, - }, - }) - } catch (error) { - logger.error('Admin API: Failed to add organization member', { error, organizationId }) - return internalErrorResponse('Failed to add organization member') - } -}) + return singleResponse({ + ...data, + action: 'created' as const, + billingActions: { + proUsageSnapshotted: result.billingActions.proUsageSnapshotted, + proCancelledAtPeriodEnd: result.billingActions.proCancelledAtPeriodEnd, + }, + }) + } catch (error) { + logger.error('Admin API: Failed to add organization member', { error, organizationId }) + return internalErrorResponse('Failed to add organization member') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 5542d8b3131..caf504e12e2 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -21,6 +21,7 @@ import { member, organization, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, inArray } from 'drizzle-orm' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -40,102 +41,106 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId } = await context.params - try { - const [orgData] = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + try { + const [orgData] = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!orgData) { - return notFoundResponse('Organization') - } + if (!orgData) { + return notFoundResponse('Organization') + } - const [memberCountResult, subscriptionData] = await Promise.all([ - db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)), - db - .select() - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + const [memberCountResult, subscriptionData] = await Promise.all([ + db.select({ count: count() }).from(member).where(eq(member.organizationId, organizationId)), + db + .select() + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) ) - ) - .limit(1), - ]) + .limit(1), + ]) - const data: AdminOrganizationDetail = { - ...toAdminOrganization(orgData), - memberCount: memberCountResult[0].count, - subscription: subscriptionData[0] ? toAdminSubscription(subscriptionData[0]) : null, - } + const data: AdminOrganizationDetail = { + ...toAdminOrganization(orgData), + memberCount: memberCountResult[0].count, + subscription: subscriptionData[0] ? toAdminSubscription(subscriptionData[0]) : null, + } - logger.info(`Admin API: Retrieved organization ${organizationId}`) + logger.info(`Admin API: Retrieved organization ${organizationId}`) - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get organization', { error, organizationId }) - return internalErrorResponse('Failed to get organization') - } -}) + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get organization', { error, organizationId }) + return internalErrorResponse('Failed to get organization') + } + }) +) -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: organizationId } = await context.params +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: organizationId } = await context.params - try { - const body = await request.json() + try { + const body = await request.json() - const [existing] = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const [existing] = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!existing) { - return notFoundResponse('Organization') - } + if (!existing) { + return notFoundResponse('Organization') + } - const updateData: Record = { - updatedAt: new Date(), - } + const updateData: Record = { + updatedAt: new Date(), + } - if (body.name !== undefined) { - if (typeof body.name !== 'string' || body.name.trim().length === 0) { - return badRequestResponse('name must be a non-empty string') + if (body.name !== undefined) { + if (typeof body.name !== 'string' || body.name.trim().length === 0) { + return badRequestResponse('name must be a non-empty string') + } + updateData.name = body.name.trim() } - updateData.name = body.name.trim() - } - if (body.slug !== undefined) { - if (typeof body.slug !== 'string' || body.slug.trim().length === 0) { - return badRequestResponse('slug must be a non-empty string') + if (body.slug !== undefined) { + if (typeof body.slug !== 'string' || body.slug.trim().length === 0) { + return badRequestResponse('slug must be a non-empty string') + } + updateData.slug = body.slug.trim() } - updateData.slug = body.slug.trim() - } - if (Object.keys(updateData).length === 1) { - return badRequestResponse( - 'No valid fields to update. Use /billing endpoint for orgUsageLimit.' - ) - } + if (Object.keys(updateData).length === 1) { + return badRequestResponse( + 'No valid fields to update. Use /billing endpoint for orgUsageLimit.' + ) + } + + const [updated] = await db + .update(organization) + .set(updateData) + .where(eq(organization.id, organizationId)) + .returning() - const [updated] = await db - .update(organization) - .set(updateData) - .where(eq(organization.id, organizationId)) - .returning() - - logger.info(`Admin API: Updated organization ${organizationId}`, { - fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }) - - return singleResponse(toAdminOrganization(updated)) - } catch (error) { - logger.error('Admin API: Failed to update organization', { error, organizationId }) - return internalErrorResponse('Failed to update organization') - } -}) + logger.info(`Admin API: Updated organization ${organizationId}`, { + fields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }) + + return singleResponse(toAdminOrganization(updated)) + } catch (error) { + logger.error('Admin API: Failed to update organization', { error, organizationId }) + return internalErrorResponse('Failed to update organization') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts index 86e156a4450..01f84b218ac 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/seats/route.ts @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getOrganizationSeatAnalytics } from '@/lib/billing/validation/seat-management' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -22,42 +23,44 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (_, context) => { - const { id: organizationId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: organizationId } = await context.params - try { - const analytics = await getOrganizationSeatAnalytics(organizationId) + try { + const analytics = await getOrganizationSeatAnalytics(organizationId) - if (!analytics) { - return notFoundResponse('Organization or subscription') - } + if (!analytics) { + return notFoundResponse('Organization or subscription') + } - const data: AdminSeatAnalytics = { - organizationId: analytics.organizationId, - organizationName: analytics.organizationName, - currentSeats: analytics.currentSeats, - maxSeats: analytics.maxSeats, - availableSeats: analytics.availableSeats, - subscriptionPlan: analytics.subscriptionPlan, - canAddSeats: analytics.canAddSeats, - utilizationRate: analytics.utilizationRate, - activeMembers: analytics.activeMembers, - inactiveMembers: analytics.inactiveMembers, - memberActivity: analytics.memberActivity.map((m) => ({ - userId: m.userId, - userName: m.userName, - userEmail: m.userEmail, - role: m.role, - joinedAt: m.joinedAt.toISOString(), - lastActive: m.lastActive?.toISOString() ?? null, - })), - } + const data: AdminSeatAnalytics = { + organizationId: analytics.organizationId, + organizationName: analytics.organizationName, + currentSeats: analytics.currentSeats, + maxSeats: analytics.maxSeats, + availableSeats: analytics.availableSeats, + subscriptionPlan: analytics.subscriptionPlan, + canAddSeats: analytics.canAddSeats, + utilizationRate: analytics.utilizationRate, + activeMembers: analytics.activeMembers, + inactiveMembers: analytics.inactiveMembers, + memberActivity: analytics.memberActivity.map((m) => ({ + userId: m.userId, + userName: m.userName, + userEmail: m.userEmail, + role: m.role, + joinedAt: m.joinedAt.toISOString(), + lastActive: m.lastActive?.toISOString() ?? null, + })), + } - logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`) + logger.info(`Admin API: Retrieved seat analytics for organization ${organizationId}`) - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get organization seats', { error, organizationId }) - return internalErrorResponse('Failed to get organization seats') - } -}) + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get organization seats', { error, organizationId }) + return internalErrorResponse('Failed to get organization seats') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index f4da57737e6..4daf7863b31 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -26,6 +26,7 @@ import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -43,112 +44,116 @@ import { const logger = createLogger('AdminOrganizationsAPI') -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const [countResult, organizations] = await Promise.all([ - db.select({ total: count() }).from(organization), - db.select().from(organization).orderBy(organization.name).limit(limit).offset(offset), - ]) + try { + const [countResult, organizations] = await Promise.all([ + db.select({ total: count() }).from(organization), + db.select().from(organization).orderBy(organization.name).limit(limit).offset(offset), + ]) - const total = countResult[0].total - const data: AdminOrganization[] = organizations.map(toAdminOrganization) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminOrganization[] = organizations.map(toAdminOrganization) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} organizations (total: ${total})`) + logger.info(`Admin API: Listed ${data.length} organizations (total: ${total})`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list organizations', { error }) - return internalErrorResponse('Failed to list organizations') - } -}) - -export const POST = withAdminAuth(async (request) => { - try { - const body = await request.json() - - if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) { - return badRequestResponse('name is required') - } - - if (!body.ownerId || typeof body.ownerId !== 'string') { - return badRequestResponse('ownerId is required') - } - - const [ownerData] = await db - .select({ id: user.id, name: user.name }) - .from(user) - .where(eq(user.id, body.ownerId)) - .limit(1) - - if (!ownerData) { - return notFoundResponse('Owner user') + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list organizations', { error }) + return internalErrorResponse('Failed to list organizations') } + }) +) + +export const POST = withRouteHandler( + withAdminAuth(async (request) => { + try { + const body = await request.json() + + if (!body.name || typeof body.name !== 'string' || body.name.trim().length === 0) { + return badRequestResponse('name is required') + } + + if (!body.ownerId || typeof body.ownerId !== 'string') { + return badRequestResponse('ownerId is required') + } + + const [ownerData] = await db + .select({ id: user.id, name: user.name }) + .from(user) + .where(eq(user.id, body.ownerId)) + .limit(1) + + if (!ownerData) { + return notFoundResponse('Owner user') + } + + const [existingMembership] = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, body.ownerId)) + .limit(1) + + if (existingMembership) { + return badRequestResponse( + 'User is already a member of another organization. Users can only belong to one organization at a time.' + ) + } + + const name = body.name.trim() + const slug = + body.slug?.trim() || + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + + const organizationId = generateId() + const memberId = generateId() + const now = new Date() + + await db.transaction(async (tx) => { + await tx.insert(organization).values({ + id: organizationId, + name, + slug, + createdAt: now, + updatedAt: now, + }) + + await tx.insert(member).values({ + id: memberId, + userId: body.ownerId, + organizationId, + role: 'owner', + createdAt: now, + }) + }) - const [existingMembership] = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, body.ownerId)) - .limit(1) + const [createdOrg] = await db + .select() + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (existingMembership) { - return badRequestResponse( - 'User is already a member of another organization. Users can only belong to one organization at a time.' - ) - } - - const name = body.name.trim() - const slug = - body.slug?.trim() || - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - - const organizationId = generateId() - const memberId = generateId() - const now = new Date() - - await db.transaction(async (tx) => { - await tx.insert(organization).values({ - id: organizationId, + logger.info(`Admin API: Created organization ${organizationId}`, { name, slug, - createdAt: now, - updatedAt: now, + ownerId: body.ownerId, + memberId, }) - await tx.insert(member).values({ - id: memberId, - userId: body.ownerId, - organizationId, - role: 'owner', - createdAt: now, + return singleResponse({ + ...toAdminOrganization(createdOrg), + memberId, }) - }) - - const [createdOrg] = await db - .select() - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) - - logger.info(`Admin API: Created organization ${organizationId}`, { - name, - slug, - ownerId: body.ownerId, - memberId, - }) - - return singleResponse({ - ...toAdminOrganization(createdOrg), - memberId, - }) - } catch (error) { - logger.error('Admin API: Failed to create organization', { error }) - return internalErrorResponse('Failed to create organization') - } -}) + } catch (error) { + logger.error('Admin API: Failed to create organization', { error }) + return internalErrorResponse('Failed to create organization') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts index 73824207197..7d1ebfc9452 100644 --- a/apps/sim/app/api/v1/admin/referral-campaigns/route.ts +++ b/apps/sim/app/api/v1/admin/referral-campaigns/route.ts @@ -30,6 +30,7 @@ import type Stripe from 'stripe' import { isPro, isTeam } from '@/lib/billing/plan-helpers' import { getPlans } from '@/lib/billing/plans' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -151,193 +152,197 @@ async function resolveProductIds(stripe: Stripe, targets: AppliesTo[]): Promise< return [...productIds] } -export const GET = withAdminAuth(async (request) => { - try { - const stripe = requireStripeClient() - const url = new URL(request.url) - - const limitParam = url.searchParams.get('limit') - let limit = limitParam ? Number.parseInt(limitParam, 10) : 50 - if (Number.isNaN(limit) || limit < 1) limit = 50 - if (limit > 100) limit = 100 +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + try { + const stripe = requireStripeClient() + const url = new URL(request.url) - const startingAfter = url.searchParams.get('starting_after') || undefined - const activeFilter = url.searchParams.get('active') + const limitParam = url.searchParams.get('limit') + let limit = limitParam ? Number.parseInt(limitParam, 10) : 50 + if (Number.isNaN(limit) || limit < 1) limit = 50 + if (limit > 100) limit = 100 - const listParams: Record = { limit } - if (startingAfter) listParams.starting_after = startingAfter - if (activeFilter === 'true') listParams.active = true - else if (activeFilter === 'false') listParams.active = false + const startingAfter = url.searchParams.get('starting_after') || undefined + const activeFilter = url.searchParams.get('active') - const promoCodes = await stripe.promotionCodes.list(listParams) + const listParams: Record = { limit } + if (startingAfter) listParams.starting_after = startingAfter + if (activeFilter === 'true') listParams.active = true + else if (activeFilter === 'false') listParams.active = false - const data = promoCodes.data.map(formatPromoCode) + const promoCodes = await stripe.promotionCodes.list(listParams) - logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`) + const data = promoCodes.data.map(formatPromoCode) - return NextResponse.json({ - data, - hasMore: promoCodes.has_more, - ...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}), - }) - } catch (error) { - logger.error('Admin API: Failed to list promotion codes', { error }) - return internalErrorResponse('Failed to list promotion codes') - } -}) - -export const POST = withAdminAuth(async (request) => { - try { - const stripe = requireStripeClient() - const body = await request.json() - - const { - name, - percentOff, - code, - duration, - durationInMonths, - maxRedemptions, - expiresAt, - appliesTo, - } = body - - if (!name || typeof name !== 'string' || name.trim().length === 0) { - return badRequestResponse('name is required and must be a non-empty string') - } + logger.info(`Admin API: Listed ${data.length} Stripe promotion codes`) - if ( - typeof percentOff !== 'number' || - !Number.isFinite(percentOff) || - percentOff < 1 || - percentOff > 100 - ) { - return badRequestResponse('percentOff must be a number between 1 and 100') + return NextResponse.json({ + data, + hasMore: promoCodes.has_more, + ...(data.length > 0 ? { nextCursor: data[data.length - 1].id } : {}), + }) + } catch (error) { + logger.error('Admin API: Failed to list promotion codes', { error }) + return internalErrorResponse('Failed to list promotion codes') } + }) +) - const effectiveDuration: Duration = duration ?? 'once' - if (!VALID_DURATIONS.includes(effectiveDuration)) { - return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`) - } +export const POST = withRouteHandler( + withAdminAuth(async (request) => { + try { + const stripe = requireStripeClient() + const body = await request.json() + + const { + name, + percentOff, + code, + duration, + durationInMonths, + maxRedemptions, + expiresAt, + appliesTo, + } = body + + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return badRequestResponse('name is required and must be a non-empty string') + } - if (effectiveDuration === 'repeating') { if ( - typeof durationInMonths !== 'number' || - !Number.isInteger(durationInMonths) || - durationInMonths < 1 + typeof percentOff !== 'number' || + !Number.isFinite(percentOff) || + percentOff < 1 || + percentOff > 100 ) { - return badRequestResponse( - 'durationInMonths is required and must be a positive integer when duration is "repeating"' - ) + return badRequestResponse('percentOff must be a number between 1 and 100') } - } - if (code !== undefined && code !== null) { - if (typeof code !== 'string') { - return badRequestResponse('code must be a string or null') - } - if (code.trim().length < 6) { - return badRequestResponse('code must be at least 6 characters') + const effectiveDuration: Duration = duration ?? 'once' + if (!VALID_DURATIONS.includes(effectiveDuration)) { + return badRequestResponse(`duration must be one of: ${VALID_DURATIONS.join(', ')}`) } - } - if (maxRedemptions !== undefined && maxRedemptions !== null) { - if ( - typeof maxRedemptions !== 'number' || - !Number.isInteger(maxRedemptions) || - maxRedemptions < 1 - ) { - return badRequestResponse('maxRedemptions must be a positive integer') + if (effectiveDuration === 'repeating') { + if ( + typeof durationInMonths !== 'number' || + !Number.isInteger(durationInMonths) || + durationInMonths < 1 + ) { + return badRequestResponse( + 'durationInMonths is required and must be a positive integer when duration is "repeating"' + ) + } } - } - if (expiresAt !== undefined && expiresAt !== null) { - const parsed = new Date(expiresAt) - if (Number.isNaN(parsed.getTime())) { - return badRequestResponse('expiresAt must be a valid ISO 8601 date string') + if (code !== undefined && code !== null) { + if (typeof code !== 'string') { + return badRequestResponse('code must be a string or null') + } + if (code.trim().length < 6) { + return badRequestResponse('code must be at least 6 characters') + } } - if (parsed.getTime() <= Date.now()) { - return badRequestResponse('expiresAt must be in the future') - } - } - if (appliesTo !== undefined && appliesTo !== null) { - if (!Array.isArray(appliesTo) || appliesTo.length === 0) { - return badRequestResponse('appliesTo must be a non-empty array') + if (maxRedemptions !== undefined && maxRedemptions !== null) { + if ( + typeof maxRedemptions !== 'number' || + !Number.isInteger(maxRedemptions) || + maxRedemptions < 1 + ) { + return badRequestResponse('maxRedemptions must be a positive integer') + } } - const invalid = appliesTo.filter( - (v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo) - ) - if (invalid.length > 0) { - return badRequestResponse( - `appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}` - ) + + if (expiresAt !== undefined && expiresAt !== null) { + const parsed = new Date(expiresAt) + if (Number.isNaN(parsed.getTime())) { + return badRequestResponse('expiresAt must be a valid ISO 8601 date string') + } + if (parsed.getTime() <= Date.now()) { + return badRequestResponse('expiresAt must be in the future') + } } - } - let appliesToProducts: string[] | undefined - if (appliesTo?.length) { - appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[]) - if (appliesToProducts.length === 0) { - return badRequestResponse( - 'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.' + if (appliesTo !== undefined && appliesTo !== null) { + if (!Array.isArray(appliesTo) || appliesTo.length === 0) { + return badRequestResponse('appliesTo must be a non-empty array') + } + const invalid = appliesTo.filter( + (v: unknown) => typeof v !== 'string' || !VALID_APPLIES_TO.includes(v as AppliesTo) ) + if (invalid.length > 0) { + return badRequestResponse( + `appliesTo contains invalid values: ${invalid.join(', ')}. Valid values: ${VALID_APPLIES_TO.join(', ')}` + ) + } } - } - const coupon = await stripe.coupons.create({ - name: name.trim(), - percent_off: percentOff, - duration: effectiveDuration, - ...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}), - ...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}), - }) - - let promoCode - try { - const promoParams: Stripe.PromotionCodeCreateParams = { - coupon: coupon.id, - ...(code ? { code: code.trim().toUpperCase() } : {}), - ...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}), - ...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}), + let appliesToProducts: string[] | undefined + if (appliesTo?.length) { + appliesToProducts = await resolveProductIds(stripe, appliesTo as AppliesTo[]) + if (appliesToProducts.length === 0) { + return badRequestResponse( + 'Could not resolve any Stripe products for the specified plan categories. Ensure price IDs are configured.' + ) + } } - promoCode = await stripe.promotionCodes.create(promoParams) - } catch (promoError) { + const coupon = await stripe.coupons.create({ + name: name.trim(), + percent_off: percentOff, + duration: effectiveDuration, + ...(effectiveDuration === 'repeating' ? { duration_in_months: durationInMonths } : {}), + ...(appliesToProducts ? { applies_to: { products: appliesToProducts } } : {}), + }) + + let promoCode try { - await stripe.coupons.del(coupon.id) - } catch (cleanupError) { - logger.error( - 'Admin API: Failed to clean up orphaned coupon after promo code creation failed', - { - couponId: coupon.id, - cleanupError, - } - ) + const promoParams: Stripe.PromotionCodeCreateParams = { + coupon: coupon.id, + ...(code ? { code: code.trim().toUpperCase() } : {}), + ...(maxRedemptions ? { max_redemptions: maxRedemptions } : {}), + ...(expiresAt ? { expires_at: Math.floor(new Date(expiresAt).getTime() / 1000) } : {}), + } + + promoCode = await stripe.promotionCodes.create(promoParams) + } catch (promoError) { + try { + await stripe.coupons.del(coupon.id) + } catch (cleanupError) { + logger.error( + 'Admin API: Failed to clean up orphaned coupon after promo code creation failed', + { + couponId: coupon.id, + cleanupError, + } + ) + } + throw promoError } - throw promoError - } - - logger.info('Admin API: Created Stripe promotion code', { - promoCodeId: promoCode.id, - code: promoCode.code, - couponId: coupon.id, - percentOff, - duration: effectiveDuration, - ...(appliesTo ? { appliesTo } : {}), - }) - return singleResponse(formatPromoCode(promoCode)) - } catch (error) { - if ( - error instanceof Error && - 'type' in error && - (error as { type: string }).type === 'StripeInvalidRequestError' - ) { - logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message }) - return badRequestResponse(error.message) + logger.info('Admin API: Created Stripe promotion code', { + promoCodeId: promoCode.id, + code: promoCode.code, + couponId: coupon.id, + percentOff, + duration: effectiveDuration, + ...(appliesTo ? { appliesTo } : {}), + }) + + return singleResponse(formatPromoCode(promoCode)) + } catch (error) { + if ( + error instanceof Error && + 'type' in error && + (error as { type: string }).type === 'StripeInvalidRequestError' + ) { + logger.warn('Admin API: Stripe rejected promotion code request', { error: error.message }) + return badRequestResponse(error.message) + } + logger.error('Admin API: Failed to create promotion code', { error }) + return internalErrorResponse('Failed to create promotion code') } - logger.error('Admin API: Failed to create promotion code', { error }) - return internalErrorResponse('Failed to create promotion code') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts index 50ba40f3338..38fa80b7e83 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -28,6 +28,7 @@ import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -43,110 +44,114 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (_, context) => { - const { id: subscriptionId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: subscriptionId } = await context.params - try { - const [subData] = await db - .select() - .from(subscription) - .where(eq(subscription.id, subscriptionId)) - .limit(1) - - if (!subData) { - return notFoundResponse('Subscription') - } + try { + const [subData] = await db + .select() + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + .limit(1) - logger.info(`Admin API: Retrieved subscription ${subscriptionId}`) - - return singleResponse(toAdminSubscription(subData)) - } catch (error) { - logger.error('Admin API: Failed to get subscription', { error, subscriptionId }) - return internalErrorResponse('Failed to get subscription') - } -}) - -export const DELETE = withAdminAuthParams(async (request, context) => { - const { id: subscriptionId } = await context.params - const url = new URL(request.url) - const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true' - const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)' - - try { - const [existing] = await db - .select() - .from(subscription) - .where(eq(subscription.id, subscriptionId)) - .limit(1) - - if (!existing) { - return notFoundResponse('Subscription') - } + if (!subData) { + return notFoundResponse('Subscription') + } - if (existing.status === 'canceled') { - return badRequestResponse('Subscription is already canceled') - } + logger.info(`Admin API: Retrieved subscription ${subscriptionId}`) - if (!existing.stripeSubscriptionId) { - return badRequestResponse('Subscription has no Stripe subscription ID') + return singleResponse(toAdminSubscription(subData)) + } catch (error) { + logger.error('Admin API: Failed to get subscription', { error, subscriptionId }) + return internalErrorResponse('Failed to get subscription') } - - const stripe = requireStripeClient() - - if (atPeriodEnd) { - // Schedule cancellation at period end - await stripe.subscriptions.update(existing.stripeSubscriptionId, { - cancel_at_period_end: true, - }) - - // Update DB (webhooks don't sync cancelAtPeriodEnd) - await db - .update(subscription) - .set({ cancelAtPeriodEnd: true }) + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: subscriptionId } = await context.params + const url = new URL(request.url) + const atPeriodEnd = url.searchParams.get('atPeriodEnd') === 'true' + const reason = url.searchParams.get('reason') || 'Admin cancellation (no reason provided)' + + try { + const [existing] = await db + .select() + .from(subscription) .where(eq(subscription.id, subscriptionId)) + .limit(1) + + if (!existing) { + return notFoundResponse('Subscription') + } + + if (existing.status === 'canceled') { + return badRequestResponse('Subscription is already canceled') + } + + if (!existing.stripeSubscriptionId) { + return badRequestResponse('Subscription has no Stripe subscription ID') + } + + const stripe = requireStripeClient() + + if (atPeriodEnd) { + // Schedule cancellation at period end + await stripe.subscriptions.update(existing.stripeSubscriptionId, { + cancel_at_period_end: true, + }) + + // Update DB (webhooks don't sync cancelAtPeriodEnd) + await db + .update(subscription) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscription.id, subscriptionId)) + + logger.info('Admin API: Scheduled subscription cancellation at period end', { + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + plan: existing.plan, + referenceId: existing.referenceId, + periodEnd: existing.periodEnd, + reason, + }) + + return singleResponse({ + success: true, + message: 'Subscription scheduled to cancel at period end.', + subscriptionId, + stripeSubscriptionId: existing.stripeSubscriptionId, + atPeriodEnd: true, + periodEnd: existing.periodEnd?.toISOString() ?? null, + }) + } + + // Immediate cancellation + await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { + prorate: true, + invoice_now: true, + }) - logger.info('Admin API: Scheduled subscription cancellation at period end', { + logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', { subscriptionId, stripeSubscriptionId: existing.stripeSubscriptionId, plan: existing.plan, referenceId: existing.referenceId, - periodEnd: existing.periodEnd, reason, }) return singleResponse({ success: true, - message: 'Subscription scheduled to cancel at period end.', + message: 'Subscription cancellation triggered. Webhook will complete cleanup.', subscriptionId, stripeSubscriptionId: existing.stripeSubscriptionId, - atPeriodEnd: true, - periodEnd: existing.periodEnd?.toISOString() ?? null, + atPeriodEnd: false, }) + } catch (error) { + logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId }) + return internalErrorResponse('Failed to cancel subscription') } - - // Immediate cancellation - await stripe.subscriptions.cancel(existing.stripeSubscriptionId, { - prorate: true, - invoice_now: true, - }) - - logger.info('Admin API: Triggered immediate subscription cancellation on Stripe', { - subscriptionId, - stripeSubscriptionId: existing.stripeSubscriptionId, - plan: existing.plan, - referenceId: existing.referenceId, - reason, - }) - - return singleResponse({ - success: true, - message: 'Subscription cancellation triggered. Webhook will complete cleanup.', - subscriptionId, - stripeSubscriptionId: existing.stripeSubscriptionId, - atPeriodEnd: false, - }) - } catch (error) { - logger.error('Admin API: Failed to cancel subscription', { error, subscriptionId }) - return internalErrorResponse('Failed to cancel subscription') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/subscriptions/route.ts b/apps/sim/app/api/v1/admin/subscriptions/route.ts index 146d5c307b9..8fa4628114c 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/route.ts @@ -16,6 +16,7 @@ import { db } from '@sim/db' import { subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, type SQL } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' import { @@ -27,43 +28,45 @@ import { const logger = createLogger('AdminSubscriptionsAPI') -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - const planFilter = url.searchParams.get('plan') - const statusFilter = url.searchParams.get('status') +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + const planFilter = url.searchParams.get('plan') + const statusFilter = url.searchParams.get('status') - try { - const conditions: SQL[] = [] - if (planFilter) { - conditions.push(eq(subscription.plan, planFilter)) - } - if (statusFilter) { - conditions.push(eq(subscription.status, statusFilter)) - } + try { + const conditions: SQL[] = [] + if (planFilter) { + conditions.push(eq(subscription.plan, planFilter)) + } + if (statusFilter) { + conditions.push(eq(subscription.status, statusFilter)) + } - const whereClause = conditions.length > 0 ? and(...conditions) : undefined + const whereClause = conditions.length > 0 ? and(...conditions) : undefined - const [countResult, subscriptions] = await Promise.all([ - db.select({ total: count() }).from(subscription).where(whereClause), - db - .select() - .from(subscription) - .where(whereClause) - .orderBy(subscription.plan) - .limit(limit) - .offset(offset), - ]) + const [countResult, subscriptions] = await Promise.all([ + db.select({ total: count() }).from(subscription).where(whereClause), + db + .select() + .from(subscription) + .where(whereClause) + .orderBy(subscription.plan) + .limit(limit) + .offset(offset), + ]) - const total = countResult[0].total - const data: AdminSubscription[] = subscriptions.map(toAdminSubscription) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminSubscription[] = subscriptions.map(toAdminSubscription) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} subscriptions (total: ${total})`) + logger.info(`Admin API: Listed ${data.length} subscriptions (total: ${total})`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list subscriptions', { error }) - return internalErrorResponse('Failed to list subscriptions') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list subscriptions', { error }) + return internalErrorResponse('Failed to list subscriptions') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts index 1639db0baea..4b04b9ab23b 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/billing/route.ts @@ -25,6 +25,7 @@ import { eq, or } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isOrgPlan } from '@/lib/billing/plan-helpers' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -43,224 +44,228 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (_, context) => { - const { id: userId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: userId } = await context.params + + try { + const [userData] = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + stripeCustomerId: user.stripeCustomerId, + }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + if (!userData) { + return notFoundResponse('User') + } - try { - const [userData] = await db - .select({ - id: user.id, - name: user.name, - email: user.email, - stripeCustomerId: user.stripeCustomerId, - }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) + const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1) + + const memberOrgs = await db + .select({ + organizationId: member.organizationId, + organizationName: organization.name, + role: member.role, + }) + .from(member) + .innerJoin(organization, eq(member.organizationId, organization.id)) + .where(eq(member.userId, userId)) + + const orgIds = memberOrgs.map((m) => m.organizationId) + + const subscriptions = await db + .select() + .from(subscription) + .where( + orgIds.length > 0 + ? or( + eq(subscription.referenceId, userId), + ...orgIds.map((orgId) => eq(subscription.referenceId, orgId)) + ) + : eq(subscription.referenceId, userId) + ) - if (!userData) { - return notFoundResponse('User') - } + const data: AdminUserBillingWithSubscription = { + userId: userData.id, + userName: userData.name, + userEmail: userData.email, + stripeCustomerId: userData.stripeCustomerId, + totalManualExecutions: stats?.totalManualExecutions ?? 0, + totalApiCalls: stats?.totalApiCalls ?? 0, + totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0, + totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0, + totalChatExecutions: stats?.totalChatExecutions ?? 0, + totalMcpExecutions: stats?.totalMcpExecutions ?? 0, + totalA2aExecutions: stats?.totalA2aExecutions ?? 0, + totalTokensUsed: stats?.totalTokensUsed ?? 0, + totalCost: stats?.totalCost ?? '0', + currentUsageLimit: stats?.currentUsageLimit ?? null, + currentPeriodCost: stats?.currentPeriodCost ?? '0', + lastPeriodCost: stats?.lastPeriodCost ?? null, + billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0', + storageUsedBytes: stats?.storageUsedBytes ?? 0, + lastActive: stats?.lastActive?.toISOString() ?? null, + billingBlocked: stats?.billingBlocked ?? false, + totalCopilotCost: stats?.totalCopilotCost ?? '0', + currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0', + lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null, + totalCopilotTokens: stats?.totalCopilotTokens ?? 0, + totalCopilotCalls: stats?.totalCopilotCalls ?? 0, + subscriptions: subscriptions.map(toAdminSubscription), + organizationMemberships: memberOrgs.map((m) => ({ + organizationId: m.organizationId, + organizationName: m.organizationName, + role: m.role, + })), + } - const [stats] = await db.select().from(userStats).where(eq(userStats.userId, userId)).limit(1) + logger.info(`Admin API: Retrieved billing for user ${userId}`) - const memberOrgs = await db - .select({ - organizationId: member.organizationId, - organizationName: organization.name, - role: member.role, - }) - .from(member) - .innerJoin(organization, eq(member.organizationId, organization.id)) - .where(eq(member.userId, userId)) - - const orgIds = memberOrgs.map((m) => m.organizationId) - - const subscriptions = await db - .select() - .from(subscription) - .where( - orgIds.length > 0 - ? or( - eq(subscription.referenceId, userId), - ...orgIds.map((orgId) => eq(subscription.referenceId, orgId)) - ) - : eq(subscription.referenceId, userId) - ) - - const data: AdminUserBillingWithSubscription = { - userId: userData.id, - userName: userData.name, - userEmail: userData.email, - stripeCustomerId: userData.stripeCustomerId, - totalManualExecutions: stats?.totalManualExecutions ?? 0, - totalApiCalls: stats?.totalApiCalls ?? 0, - totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0, - totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0, - totalChatExecutions: stats?.totalChatExecutions ?? 0, - totalMcpExecutions: stats?.totalMcpExecutions ?? 0, - totalA2aExecutions: stats?.totalA2aExecutions ?? 0, - totalTokensUsed: stats?.totalTokensUsed ?? 0, - totalCost: stats?.totalCost ?? '0', - currentUsageLimit: stats?.currentUsageLimit ?? null, - currentPeriodCost: stats?.currentPeriodCost ?? '0', - lastPeriodCost: stats?.lastPeriodCost ?? null, - billedOverageThisPeriod: stats?.billedOverageThisPeriod ?? '0', - storageUsedBytes: stats?.storageUsedBytes ?? 0, - lastActive: stats?.lastActive?.toISOString() ?? null, - billingBlocked: stats?.billingBlocked ?? false, - totalCopilotCost: stats?.totalCopilotCost ?? '0', - currentPeriodCopilotCost: stats?.currentPeriodCopilotCost ?? '0', - lastPeriodCopilotCost: stats?.lastPeriodCopilotCost ?? null, - totalCopilotTokens: stats?.totalCopilotTokens ?? 0, - totalCopilotCalls: stats?.totalCopilotCalls ?? 0, - subscriptions: subscriptions.map(toAdminSubscription), - organizationMemberships: memberOrgs.map((m) => ({ - organizationId: m.organizationId, - organizationName: m.organizationName, - role: m.role, - })), + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get user billing', { error, userId }) + return internalErrorResponse('Failed to get user billing') } + }) +) - logger.info(`Admin API: Retrieved billing for user ${userId}`) +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: userId } = await context.params - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get user billing', { error, userId }) - return internalErrorResponse('Failed to get user billing') - } -}) + try { + const body = await request.json() + const reason = body.reason || 'Admin update (no reason provided)' -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: userId } = await context.params + const [userData] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) - try { - const body = await request.json() - const reason = body.reason || 'Admin update (no reason provided)' - - const [userData] = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) + if (!userData) { + return notFoundResponse('User') + } - if (!userData) { - return notFoundResponse('User') - } + const [existingStats] = await db + .select() + .from(userStats) + .where(eq(userStats.userId, userId)) + .limit(1) - const [existingStats] = await db - .select() - .from(userStats) - .where(eq(userStats.userId, userId)) - .limit(1) + const userSubscription = await getHighestPrioritySubscription(userId) + const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan) - const userSubscription = await getHighestPrioritySubscription(userId) - const isTeamOrEnterpriseMember = userSubscription && isOrgPlan(userSubscription.plan) + const [orgMembership] = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) - const [orgMembership] = await db - .select({ organizationId: member.organizationId }) - .from(member) - .where(eq(member.userId, userId)) - .limit(1) + const updateData: Record = {} + const updated: string[] = [] + const warnings: string[] = [] - const updateData: Record = {} - const updated: string[] = [] - const warnings: string[] = [] + if (body.currentUsageLimit !== undefined) { + if (isTeamOrEnterpriseMember && orgMembership) { + warnings.push( + 'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.' + ) + } - if (body.currentUsageLimit !== undefined) { - if (isTeamOrEnterpriseMember && orgMembership) { - warnings.push( - 'User is a team/enterprise member. Individual limits may be ignored in favor of organization limits.' - ) + if (body.currentUsageLimit === null) { + updateData.currentUsageLimit = null + } else if (typeof body.currentUsageLimit === 'number' && body.currentUsageLimit >= 0) { + const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0') + if (body.currentUsageLimit < currentCost) { + warnings.push( + `New limit ($${body.currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.` + ) + } + updateData.currentUsageLimit = body.currentUsageLimit.toFixed(2) + } else { + return badRequestResponse('currentUsageLimit must be a non-negative number or null') + } + updateData.usageLimitUpdatedAt = new Date() + updated.push('currentUsageLimit') } - if (body.currentUsageLimit === null) { - updateData.currentUsageLimit = null - } else if (typeof body.currentUsageLimit === 'number' && body.currentUsageLimit >= 0) { - const currentCost = Number.parseFloat(existingStats?.currentPeriodCost || '0') - if (body.currentUsageLimit < currentCost) { + if (body.billingBlocked !== undefined) { + if (typeof body.billingBlocked !== 'boolean') { + return badRequestResponse('billingBlocked must be a boolean') + } + + if (body.billingBlocked === false && existingStats?.billingBlocked === true) { warnings.push( - `New limit ($${body.currentUsageLimit.toFixed(2)}) is below current usage ($${currentCost.toFixed(2)}). User may be immediately blocked.` + 'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.' ) } - updateData.currentUsageLimit = body.currentUsageLimit.toFixed(2) - } else { - return badRequestResponse('currentUsageLimit must be a non-negative number or null') - } - updateData.usageLimitUpdatedAt = new Date() - updated.push('currentUsageLimit') - } - if (body.billingBlocked !== undefined) { - if (typeof body.billingBlocked !== 'boolean') { - return badRequestResponse('billingBlocked must be a boolean') + updateData.billingBlocked = body.billingBlocked + // Clear the reason when unblocking + if (body.billingBlocked === false) { + updateData.billingBlockedReason = null + } + updated.push('billingBlocked') } - if (body.billingBlocked === false && existingStats?.billingBlocked === true) { + if (body.currentPeriodCost !== undefined) { + if (typeof body.currentPeriodCost !== 'number' || body.currentPeriodCost < 0) { + return badRequestResponse('currentPeriodCost must be a non-negative number') + } + + const previousCost = existingStats?.currentPeriodCost || '0' warnings.push( - 'Unblocking user. Ensure payment issues are resolved to prevent re-blocking on next invoice.' + `Manually adjusting currentPeriodCost from $${previousCost} to $${body.currentPeriodCost.toFixed(2)}. This may affect billing accuracy.` ) - } - updateData.billingBlocked = body.billingBlocked - // Clear the reason when unblocking - if (body.billingBlocked === false) { - updateData.billingBlockedReason = null + updateData.currentPeriodCost = body.currentPeriodCost.toFixed(2) + updated.push('currentPeriodCost') } - updated.push('billingBlocked') - } - if (body.currentPeriodCost !== undefined) { - if (typeof body.currentPeriodCost !== 'number' || body.currentPeriodCost < 0) { - return badRequestResponse('currentPeriodCost must be a non-negative number') + if (updated.length === 0) { + return badRequestResponse('No valid fields to update') } - const previousCost = existingStats?.currentPeriodCost || '0' - warnings.push( - `Manually adjusting currentPeriodCost from $${previousCost} to $${body.currentPeriodCost.toFixed(2)}. This may affect billing accuracy.` - ) - - updateData.currentPeriodCost = body.currentPeriodCost.toFixed(2) - updated.push('currentPeriodCost') - } + if (existingStats) { + await db.update(userStats).set(updateData).where(eq(userStats.userId, userId)) + } else { + await db.insert(userStats).values({ + id: generateShortId(), + userId, + ...updateData, + }) + } - if (updated.length === 0) { - return badRequestResponse('No valid fields to update') - } + logger.info(`Admin API: Updated billing for user ${userId}`, { + updated, + warnings, + reason, + previousValues: existingStats + ? { + currentUsageLimit: existingStats.currentUsageLimit, + billingBlocked: existingStats.billingBlocked, + currentPeriodCost: existingStats.currentPeriodCost, + } + : null, + newValues: updateData, + isTeamMember: !!orgMembership, + }) - if (existingStats) { - await db.update(userStats).set(updateData).where(eq(userStats.userId, userId)) - } else { - await db.insert(userStats).values({ - id: generateShortId(), - userId, - ...updateData, + return singleResponse({ + success: true, + updated, + warnings, + reason, }) + } catch (error) { + logger.error('Admin API: Failed to update user billing', { error, userId }) + return internalErrorResponse('Failed to update user billing') } - - logger.info(`Admin API: Updated billing for user ${userId}`, { - updated, - warnings, - reason, - previousValues: existingStats - ? { - currentUsageLimit: existingStats.currentUsageLimit, - billingBlocked: existingStats.billingBlocked, - currentPeriodCost: existingStats.currentPeriodCost, - } - : null, - newValues: updateData, - isTeamMember: !!orgMembership, - }) - - return singleResponse({ - success: true, - updated, - warnings, - reason, - }) - } catch (error) { - logger.error('Admin API: Failed to update user billing', { error, userId }) - return internalErrorResponse('Failed to update user billing') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/users/[id]/route.ts b/apps/sim/app/api/v1/admin/users/[id]/route.ts index 3700a427b10..61a8ba6e641 100644 --- a/apps/sim/app/api/v1/admin/users/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/users/[id]/route.ts @@ -10,6 +10,7 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -24,23 +25,25 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: userId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: userId } = await context.params - try { - const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1) + try { + const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1) - if (!userData) { - return notFoundResponse('User') - } + if (!userData) { + return notFoundResponse('User') + } - const data = toAdminUser(userData) + const data = toAdminUser(userData) - logger.info(`Admin API: Retrieved user ${userId}`) + logger.info(`Admin API: Retrieved user ${userId}`) - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get user', { error, userId }) - return internalErrorResponse('Failed to get user') - } -}) + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get user', { error, userId }) + return internalErrorResponse('Failed to get user') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/users/route.ts b/apps/sim/app/api/v1/admin/users/route.ts index a8400bced6c..3413952adcf 100644 --- a/apps/sim/app/api/v1/admin/users/route.ts +++ b/apps/sim/app/api/v1/admin/users/route.ts @@ -14,6 +14,7 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' import { @@ -25,25 +26,27 @@ import { const logger = createLogger('AdminUsersAPI') -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const [countResult, users] = await Promise.all([ - db.select({ total: count() }).from(user), - db.select().from(user).orderBy(user.name).limit(limit).offset(offset), - ]) + try { + const [countResult, users] = await Promise.all([ + db.select({ total: count() }).from(user), + db.select().from(user).orderBy(user.name).limit(limit).offset(offset), + ]) - const total = countResult[0].total - const data: AdminUser[] = users.map(toAdminUser) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminUser[] = users.map(toAdminUser) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} users (total: ${total})`) + logger.info(`Admin API: Listed ${data.length} users (total: ${total})`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list users', { error }) - return internalErrorResponse('Failed to list users') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list users', { error }) + return internalErrorResponse('Failed to list users') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts index d6b195fe78f..86873b8e54b 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/deploy/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -26,79 +27,83 @@ interface RouteParams { * the deployment version and audit log entries are correctly attributed to an * admin action rather than the workflow owner. */ -export const POST = withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params - const requestId = generateRequestId() - - try { - const workflowRecord = await getActiveWorkflowRecord(workflowId) - - if (!workflowRecord) { - return notFoundResponse('Workflow') - } - - const result = await performFullDeploy({ - workflowId, - userId: workflowRecord.userId, - workflowName: workflowRecord.name, - requestId, - request, - actorId: 'admin-api', - }) - - if (!result.success) { - if (result.errorCode === 'not_found') return notFoundResponse('Workflow state') - if (result.errorCode === 'validation') return badRequestResponse(result.error!) - return internalErrorResponse(result.error || 'Failed to deploy workflow') - } - - logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`) - - const response: AdminDeployResult = { - isDeployed: true, - version: result.version!, - deployedAt: result.deployedAt!.toISOString(), - warnings: result.warnings, - } - - return singleResponse(response) - } catch (error) { - logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error }) - return internalErrorResponse('Failed to deploy workflow') - } -}) - -export const DELETE = withAdminAuthParams(async (_request, context) => { - const { id: workflowId } = await context.params - const requestId = generateRequestId() - - try { - const workflowRecord = await getActiveWorkflowRecord(workflowId) - - if (!workflowRecord) { - return notFoundResponse('Workflow') +export const POST = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workflowId } = await context.params + const requestId = generateRequestId() + + try { + const workflowRecord = await getActiveWorkflowRecord(workflowId) + + if (!workflowRecord) { + return notFoundResponse('Workflow') + } + + const result = await performFullDeploy({ + workflowId, + userId: workflowRecord.userId, + workflowName: workflowRecord.name, + requestId, + request, + actorId: 'admin-api', + }) + + if (!result.success) { + if (result.errorCode === 'not_found') return notFoundResponse('Workflow state') + if (result.errorCode === 'validation') return badRequestResponse(result.error!) + return internalErrorResponse(result.error || 'Failed to deploy workflow') + } + + logger.info(`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${result.version}`) + + const response: AdminDeployResult = { + isDeployed: true, + version: result.version!, + deployedAt: result.deployedAt!.toISOString(), + warnings: result.warnings, + } + + return singleResponse(response) + } catch (error) { + logger.error(`Admin API: Failed to deploy workflow ${workflowId}`, { error }) + return internalErrorResponse('Failed to deploy workflow') } - - const result = await performFullUndeploy({ - workflowId, - userId: workflowRecord.userId, - requestId, - actorId: 'admin-api', - }) - - if (!result.success) { - return internalErrorResponse(result.error || 'Failed to undeploy workflow') + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (_request, context) => { + const { id: workflowId } = await context.params + const requestId = generateRequestId() + + try { + const workflowRecord = await getActiveWorkflowRecord(workflowId) + + if (!workflowRecord) { + return notFoundResponse('Workflow') + } + + const result = await performFullUndeploy({ + workflowId, + userId: workflowRecord.userId, + requestId, + actorId: 'admin-api', + }) + + if (!result.success) { + return internalErrorResponse(result.error || 'Failed to undeploy workflow') + } + + logger.info(`Admin API: Undeployed workflow ${workflowId}`) + + const response: AdminUndeployResult = { + isDeployed: false, + } + + return singleResponse(response) + } catch (error) { + logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error }) + return internalErrorResponse('Failed to undeploy workflow') } - - logger.info(`Admin API: Undeployed workflow ${workflowId}`) - - const response: AdminUndeployResult = { - isDeployed: false, - } - - return singleResponse(response) - } catch (error) { - logger.error(`Admin API: Failed to undeploy workflow ${workflowId}`, { error }) - return internalErrorResponse('Failed to undeploy workflow') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts index 565467444bf..1be906792ba 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/export/route.ts @@ -10,6 +10,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -29,61 +30,63 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workflowId } = await context.params - try { - const [workflowData] = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + try { + const [workflowData] = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) - if (!workflowData) { - return notFoundResponse('Workflow') - } + if (!workflowData) { + return notFoundResponse('Workflow') + } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalizedData) { - return notFoundResponse('Workflow state') - } + if (!normalizedData) { + return notFoundResponse('Workflow state') + } - const variables = parseWorkflowVariables(workflowData.variables) + const variables = parseWorkflowVariables(workflowData.variables) - const state: WorkflowExportState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: workflowData.name, - description: workflowData.description ?? undefined, - color: workflowData.color, - exportedAt: new Date().toISOString(), - }, - variables, - } + const state: WorkflowExportState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: workflowData.name, + description: workflowData.description ?? undefined, + color: workflowData.color, + exportedAt: new Date().toISOString(), + }, + variables, + } - const exportPayload: WorkflowExportPayload = { - version: '1.0', - exportedAt: new Date().toISOString(), - workflow: { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - color: workflowData.color, - workspaceId: workflowData.workspaceId, - folderId: workflowData.folderId, - }, - state, - } + const exportPayload: WorkflowExportPayload = { + version: '1.0', + exportedAt: new Date().toISOString(), + workflow: { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + color: workflowData.color, + workspaceId: workflowData.workspaceId, + folderId: workflowData.folderId, + }, + state, + } - logger.info(`Admin API: Exported workflow ${workflowId}`) + logger.info(`Admin API: Exported workflow ${workflowId}`) - return singleResponse(exportPayload) - } catch (error) { - logger.error('Admin API: Failed to export workflow', { error, workflowId }) - return internalErrorResponse('Failed to export workflow') - } -}) + return singleResponse(exportPayload) + } catch (error) { + logger.error('Admin API: Failed to export workflow', { error, workflowId }) + return internalErrorResponse('Failed to export workflow') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts index 927e6fee9d1..6416ee62433 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -17,6 +17,7 @@ import { workflowBlocks, workflowEdges } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -33,69 +34,73 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params - - try { - const workflowData = await getActiveWorkflowRecord(workflowId) - - if (!workflowData) { - return notFoundResponse('Workflow') - } - - const [blockCountResult, edgeCountResult] = await Promise.all([ - db - .select({ count: count() }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, workflowId)), - db - .select({ count: count() }) - .from(workflowEdges) - .where(eq(workflowEdges.workflowId, workflowId)), - ]) - - const data: AdminWorkflowDetail = { - ...toAdminWorkflow(workflowData), - blockCount: blockCountResult[0].count, - edgeCount: edgeCountResult[0].count, - } - - logger.info(`Admin API: Retrieved workflow ${workflowId}`) - - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get workflow', { error, workflowId }) - return internalErrorResponse('Failed to get workflow') - } -}) - -export const DELETE = withAdminAuthParams(async (_request, context) => { - const { id: workflowId } = await context.params - - try { - const workflowData = await getActiveWorkflowRecord(workflowId) - - if (!workflowData) { - return notFoundResponse('Workflow') +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workflowId } = await context.params + + try { + const workflowData = await getActiveWorkflowRecord(workflowId) + + if (!workflowData) { + return notFoundResponse('Workflow') + } + + const [blockCountResult, edgeCountResult] = await Promise.all([ + db + .select({ count: count() }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, workflowId)), + db + .select({ count: count() }) + .from(workflowEdges) + .where(eq(workflowEdges.workflowId, workflowId)), + ]) + + const data: AdminWorkflowDetail = { + ...toAdminWorkflow(workflowData), + blockCount: blockCountResult[0].count, + edgeCount: edgeCountResult[0].count, + } + + logger.info(`Admin API: Retrieved workflow ${workflowId}`) + + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get workflow', { error, workflowId }) + return internalErrorResponse('Failed to get workflow') } - - const result = await performDeleteWorkflow({ - workflowId, - userId: workflowData.userId, - skipLastWorkflowGuard: true, - requestId: `admin-workflow-${workflowId}`, - actorId: 'admin-api', - }) - - if (!result.success) { - return internalErrorResponse(result.error || 'Failed to delete workflow') + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (_request, context) => { + const { id: workflowId } = await context.params + + try { + const workflowData = await getActiveWorkflowRecord(workflowId) + + if (!workflowData) { + return notFoundResponse('Workflow') + } + + const result = await performDeleteWorkflow({ + workflowId, + userId: workflowData.userId, + skipLastWorkflowGuard: true, + requestId: `admin-workflow-${workflowId}`, + actorId: 'admin-api', + }) + + if (!result.success) { + return internalErrorResponse(result.error || 'Failed to delete workflow') + } + + logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`) + + return NextResponse.json({ success: true, workflowId }) + } catch (error) { + logger.error('Admin API: Failed to delete workflow', { error, workflowId }) + return internalErrorResponse('Failed to delete workflow') } - - logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`) - - return NextResponse.json({ success: true, workflowId }) - } catch (error) { - logger.error('Admin API: Failed to delete workflow', { error, workflowId }) - return internalErrorResponse('Failed to delete workflow') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts index 418390592fd..3b1f972d001 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/[versionId]/activate/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { performActivateVersion } from '@/lib/workflows/orchestration' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -17,53 +18,55 @@ interface RouteParams { versionId: string } -export const POST = withAdminAuthParams(async (request, context) => { - const requestId = generateRequestId() - const { id: workflowId, versionId } = await context.params +export const POST = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const requestId = generateRequestId() + const { id: workflowId, versionId } = await context.params - try { - const workflowRecord = await getActiveWorkflowRecord(workflowId) + try { + const workflowRecord = await getActiveWorkflowRecord(workflowId) - if (!workflowRecord) { - return notFoundResponse('Workflow') - } + if (!workflowRecord) { + return notFoundResponse('Workflow') + } - const versionNum = Number(versionId) - if (!Number.isFinite(versionNum) || versionNum < 1) { - return badRequestResponse('Invalid version number') - } + const versionNum = Number(versionId) + if (!Number.isFinite(versionNum) || versionNum < 1) { + return badRequestResponse('Invalid version number') + } - const result = await performActivateVersion({ - workflowId, - version: versionNum, - userId: workflowRecord.userId, - workflow: workflowRecord as Record, - requestId, - request, - actorId: 'admin-api', - }) + const result = await performActivateVersion({ + workflowId, + version: versionNum, + userId: workflowRecord.userId, + workflow: workflowRecord as Record, + requestId, + request, + actorId: 'admin-api', + }) - if (!result.success) { - if (result.errorCode === 'not_found') return notFoundResponse('Deployment version') - if (result.errorCode === 'validation') return badRequestResponse(result.error!) - return internalErrorResponse(result.error || 'Failed to activate version') - } + if (!result.success) { + if (result.errorCode === 'not_found') return notFoundResponse('Deployment version') + if (result.errorCode === 'validation') return badRequestResponse(result.error!) + return internalErrorResponse(result.error || 'Failed to activate version') + } - logger.info( - `[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}` - ) + logger.info( + `[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}` + ) - return singleResponse({ - success: true, - version: versionNum, - deployedAt: result.deployedAt!.toISOString(), - warnings: result.warnings, - }) - } catch (error) { - logger.error( - `[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`, - { error } - ) - return internalErrorResponse('Failed to activate deployment version') - } -}) + return singleResponse({ + success: true, + version: versionNum, + deployedAt: result.deployedAt!.toISOString(), + warnings: result.warnings, + }) + } catch (error) { + logger.error( + `[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`, + { error } + ) + return internalErrorResponse('Failed to activate deployment version') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts index 846f4c7f48f..5633eef91cb 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/versions/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -15,33 +16,35 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workflowId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workflowId } = await context.params - try { - const workflowRecord = await getActiveWorkflowRecord(workflowId) + try { + const workflowRecord = await getActiveWorkflowRecord(workflowId) - if (!workflowRecord) { - return notFoundResponse('Workflow') - } + if (!workflowRecord) { + return notFoundResponse('Workflow') + } + + const { versions } = await listWorkflowVersions(workflowId) + + const response: AdminDeploymentVersion[] = versions.map((v) => ({ + id: v.id, + version: v.version, + name: v.name, + isActive: v.isActive, + createdAt: v.createdAt.toISOString(), + createdBy: v.createdBy, + deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null), + })) - const { versions } = await listWorkflowVersions(workflowId) - - const response: AdminDeploymentVersion[] = versions.map((v) => ({ - id: v.id, - version: v.version, - name: v.name, - isActive: v.isActive, - createdAt: v.createdAt.toISOString(), - createdBy: v.createdBy, - deployedByName: v.deployedByName ?? (v.createdBy === 'admin-api' ? 'Admin' : null), - })) - - logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`) - - return singleResponse({ versions: response }) - } catch (error) { - logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error }) - return internalErrorResponse('Failed to list deployment versions') - } -}) + logger.info(`Admin API: Listed ${versions.length} versions for workflow ${workflowId}`) + + return singleResponse({ versions: response }) + } catch (error) { + logger.error(`Admin API: Failed to list versions for workflow ${workflowId}`, { error }) + return internalErrorResponse('Failed to list deployment versions') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/export/route.ts b/apps/sim/app/api/v1/admin/workflows/export/route.ts index d7cc28babde..1c850571061 100644 --- a/apps/sim/app/api/v1/admin/workflows/export/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/export/route.ts @@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger' import { inArray } from 'drizzle-orm' import JSZip from 'jszip' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuth } from '@/app/api/v1/admin/middleware' @@ -40,108 +41,110 @@ interface ExportRequest { ids: string[] } -export const POST = withAdminAuth(async (request) => { - const url = new URL(request.url) - const format = url.searchParams.get('format') || 'zip' +export const POST = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const format = url.searchParams.get('format') || 'zip' - let body: ExportRequest - try { - body = await request.json() - } catch { - return badRequestResponse('Invalid JSON body') - } - - if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) { - return badRequestResponse('ids must be a non-empty array of workflow IDs') - } - - try { - const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids)) - - if (workflows.length === 0) { - return badRequestResponse('No workflows found with the provided IDs') + let body: ExportRequest + try { + body = await request.json() + } catch { + return badRequestResponse('Invalid JSON body') } - const workflowExports: WorkflowExportPayload[] = [] + if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) { + return badRequestResponse('ids must be a non-empty array of workflow IDs') + } - for (const wf of workflows) { - try { - const normalizedData = await loadWorkflowFromNormalizedTables(wf.id) + try { + const workflows = await db.select().from(workflow).where(inArray(workflow.id, body.ids)) - if (!normalizedData) { - logger.warn(`Skipping workflow ${wf.id} - no normalized data found`) - continue - } + if (workflows.length === 0) { + return badRequestResponse('No workflows found with the provided IDs') + } - const variables = parseWorkflowVariables(wf.variables) - - const state: WorkflowExportState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: wf.name, - description: wf.description ?? undefined, - color: wf.color, + const workflowExports: WorkflowExportPayload[] = [] + + for (const wf of workflows) { + try { + const normalizedData = await loadWorkflowFromNormalizedTables(wf.id) + + if (!normalizedData) { + logger.warn(`Skipping workflow ${wf.id} - no normalized data found`) + continue + } + + const variables = parseWorkflowVariables(wf.variables) + + const state: WorkflowExportState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: wf.name, + description: wf.description ?? undefined, + color: wf.color, + exportedAt: new Date().toISOString(), + }, + variables, + } + + const exportPayload: WorkflowExportPayload = { + version: '1.0', exportedAt: new Date().toISOString(), - }, - variables, + workflow: { + id: wf.id, + name: wf.name, + description: wf.description, + color: wf.color, + workspaceId: wf.workspaceId, + folderId: wf.folderId, + }, + state, + } + + workflowExports.push(exportPayload) + } catch (error) { + logger.error(`Failed to load workflow ${wf.id}:`, { error }) } + } - const exportPayload: WorkflowExportPayload = { - version: '1.0', - exportedAt: new Date().toISOString(), - workflow: { - id: wf.id, - name: wf.name, - description: wf.description, - color: wf.color, - workspaceId: wf.workspaceId, - folderId: wf.folderId, - }, - state, - } + logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) - workflowExports.push(exportPayload) - } catch (error) { - logger.error(`Failed to load workflow ${wf.id}:`, { error }) + if (format === 'json') { + return listResponse(workflowExports, { + total: workflowExports.length, + limit: workflowExports.length, + offset: 0, + hasMore: false, + }) } - } - logger.info(`Admin API: Exporting ${workflowExports.length} workflows`) + const zip = new JSZip() - if (format === 'json') { - return listResponse(workflowExports, { - total: workflowExports.length, - limit: workflowExports.length, - offset: 0, - hasMore: false, - }) - } + for (const exportPayload of workflowExports) { + const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json` + zip.file(filename, JSON.stringify(exportPayload, null, 2)) + } - const zip = new JSZip() + const zipBlob = await zip.generateAsync({ type: 'blob' }) + const arrayBuffer = await zipBlob.arrayBuffer() - for (const exportPayload of workflowExports) { - const filename = `${sanitizePathSegment(exportPayload.workflow.name)}.json` - zip.file(filename, JSON.stringify(exportPayload, null, 2)) - } + const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip` - const zipBlob = await zip.generateAsync({ type: 'blob' }) - const arrayBuffer = await zipBlob.arrayBuffer() - - const filename = `workflows-export-${new Date().toISOString().split('T')[0]}.zip` - - return new NextResponse(arrayBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': arrayBuffer.byteLength.toString(), - }, - }) - } catch (error) { - logger.error('Admin API: Failed to export workflows', { error, ids: body.ids }) - return internalErrorResponse('Failed to export workflows') - } -}) + return new NextResponse(arrayBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': arrayBuffer.byteLength.toString(), + }, + }) + } catch (error) { + logger.error('Admin API: Failed to export workflows', { error, ids: body.ids }) + return internalErrorResponse('Failed to export workflows') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts index 1d384f0cf6b..557c0cf38bd 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' @@ -43,114 +44,116 @@ interface ImportSuccessResponse { success: true } -export const POST = withAdminAuth(async (request) => { - try { - const body = (await request.json()) as WorkflowImportRequest - - if (!body.workspaceId) { - return badRequestResponse('workspaceId is required') - } - - if (!body.workflow) { - return badRequestResponse('workflow is required') - } - - const { workspaceId, folderId, name: overrideName } = body - - const [workspaceData] = await db - .select({ id: workspace.id, ownerId: workspace.ownerId }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - - if (!workspaceData) { - return notFoundResponse('Workspace') - } - - const workflowContent = - typeof body.workflow === 'string' ? body.workflow : JSON.stringify(body.workflow) - - const { data: workflowData, errors } = parseWorkflowJson(workflowContent) - - if (!workflowData || errors.length > 0) { - return badRequestResponse(`Invalid workflow: ${errors.join(', ')}`) - } - - const parsedWorkflow = - typeof body.workflow === 'string' - ? (() => { - try { - return JSON.parse(body.workflow) - } catch { - return null - } - })() - : body.workflow - - const { - name: workflowName, - color: workflowColor, - description: workflowDescription, - } = extractWorkflowMetadata(parsedWorkflow, overrideName) - - const workflowId = generateId() - const now = new Date() - const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null) - - await db.insert(workflow).values({ - id: workflowId, - userId: workspaceData.ownerId, - workspaceId, - folderId: folderId || null, - name: dedupedName, - description: workflowDescription, - color: workflowColor, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) - - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData) - - if (!saveResult.success) { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`) - } - - if (workflowData.variables && Array.isArray(workflowData.variables)) { - const variablesRecord: Record = {} - workflowData.variables.forEach((v) => { - const varId = v.id || generateId() - variablesRecord[varId] = { - id: varId, - name: v.name, - type: v.type || 'string', - value: v.value, - } +export const POST = withRouteHandler( + withAdminAuth(async (request) => { + try { + const body = (await request.json()) as WorkflowImportRequest + + if (!body.workspaceId) { + return badRequestResponse('workspaceId is required') + } + + if (!body.workflow) { + return badRequestResponse('workflow is required') + } + + const { workspaceId, folderId, name: overrideName } = body + + const [workspaceData] = await db + .select({ id: workspace.id, ownerId: workspace.ownerId }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const workflowContent = + typeof body.workflow === 'string' ? body.workflow : JSON.stringify(body.workflow) + + const { data: workflowData, errors } = parseWorkflowJson(workflowContent) + + if (!workflowData || errors.length > 0) { + return badRequestResponse(`Invalid workflow: ${errors.join(', ')}`) + } + + const parsedWorkflow = + typeof body.workflow === 'string' + ? (() => { + try { + return JSON.parse(body.workflow) + } catch { + return null + } + })() + : body.workflow + + const { + name: workflowName, + color: workflowColor, + description: workflowDescription, + } = extractWorkflowMetadata(parsedWorkflow, overrideName) + + const workflowId = generateId() + const now = new Date() + const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, folderId || null) + + await db.insert(workflow).values({ + id: workflowId, + userId: workspaceData.ownerId, + workspaceId, + folderId: folderId || null, + name: dedupedName, + description: workflowDescription, + color: workflowColor, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + runCount: 0, + variables: {}, }) - await db - .update(workflow) - .set({ variables: variablesRecord, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData) + + if (!saveResult.success) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + return internalErrorResponse(`Failed to save workflow state: ${saveResult.error}`) + } + + if (workflowData.variables && Array.isArray(workflowData.variables)) { + const variablesRecord: Record = {} + workflowData.variables.forEach((v) => { + const varId = v.id || generateId() + variablesRecord[varId] = { + id: varId, + name: v.name, + type: v.type || 'string', + value: v.value, + } + }) + + await db + .update(workflow) + .set({ variables: variablesRecord, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + } + + logger.info( + `Admin API: Imported workflow ${workflowId} (${dedupedName}) into workspace ${workspaceId}` + ) + + const response: ImportSuccessResponse = { + workflowId, + name: dedupedName, + success: true, + } + + return NextResponse.json(response) + } catch (error) { + logger.error('Admin API: Failed to import workflow', { error }) + return internalErrorResponse('Failed to import workflow') } - - logger.info( - `Admin API: Imported workflow ${workflowId} (${dedupedName}) into workspace ${workspaceId}` - ) - - const response: ImportSuccessResponse = { - workflowId, - name: dedupedName, - success: true, - } - - return NextResponse.json(response) - } catch (error) { - logger.error('Admin API: Failed to import workflow', { error }) - return internalErrorResponse('Failed to import workflow') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/workflows/route.ts b/apps/sim/app/api/v1/admin/workflows/route.ts index 5344a5db633..9a13531d1f2 100644 --- a/apps/sim/app/api/v1/admin/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/route.ts @@ -14,6 +14,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' import { @@ -25,25 +26,27 @@ import { const logger = createLogger('AdminWorkflowsAPI') -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const [countResult, workflows] = await Promise.all([ - db.select({ total: count() }).from(workflow), - db.select().from(workflow).orderBy(workflow.name).limit(limit).offset(offset), - ]) + try { + const [countResult, workflows] = await Promise.all([ + db.select({ total: count() }).from(workflow), + db.select().from(workflow).orderBy(workflow.name).limit(limit).offset(offset), + ]) - const total = countResult[0].total - const data: AdminWorkflow[] = workflows.map(toAdminWorkflow) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminWorkflow[] = workflows.map(toAdminWorkflow) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} workflows (total: ${total})`) + logger.info(`Admin API: Listed ${data.length} workflows (total: ${total})`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list workflows', { error }) - return internalErrorResponse('Failed to list workflows') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list workflows', { error }) + return internalErrorResponse('Failed to list workflows') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index 6cd90556304..3f99b48d716 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -16,6 +16,7 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -37,128 +38,133 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const format = url.searchParams.get('format') || 'zip' - - try { - const [workspaceData] = await db - .select({ id: workspace.id, name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - if (!workspaceData) { - return notFoundResponse('Workspace') - } - - const workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId)) - - const folders = await db - .select() - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) - - const workflowExports: Array<{ - workflow: WorkspaceExportPayload['workflows'][number]['workflow'] - state: WorkflowExportState - }> = [] - - for (const wf of workflows) { - try { - const normalizedData = await loadWorkflowFromNormalizedTables(wf.id) +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const format = url.searchParams.get('format') || 'zip' + + try { + const [workspaceData] = await db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } - if (!normalizedData) { - logger.warn(`Skipping workflow ${wf.id} - no normalized data found`) - continue + const workflows = await db + .select() + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + + const folders = await db + .select() + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)) + + const workflowExports: Array<{ + workflow: WorkspaceExportPayload['workflows'][number]['workflow'] + state: WorkflowExportState + }> = [] + + for (const wf of workflows) { + try { + const normalizedData = await loadWorkflowFromNormalizedTables(wf.id) + + if (!normalizedData) { + logger.warn(`Skipping workflow ${wf.id} - no normalized data found`) + continue + } + + const variables = parseWorkflowVariables(wf.variables) + + const state: WorkflowExportState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + metadata: { + name: wf.name, + description: wf.description ?? undefined, + color: wf.color, + exportedAt: new Date().toISOString(), + }, + variables, + } + + workflowExports.push({ + workflow: { + id: wf.id, + name: wf.name, + description: wf.description, + color: wf.color, + workspaceId: wf.workspaceId, + folderId: wf.folderId, + }, + state, + }) + } catch (error) { + logger.error(`Failed to load workflow ${wf.id}:`, { error }) } + } - const variables = parseWorkflowVariables(wf.variables) - - const state: WorkflowExportState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - metadata: { - name: wf.name, - description: wf.description ?? undefined, - color: wf.color, - exportedAt: new Date().toISOString(), + const folderExports: FolderExportPayload[] = folders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId, + })) + + logger.info( + `Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders` + ) + + if (format === 'json') { + const exportPayload: WorkspaceExportPayload = { + version: '1.0', + exportedAt: new Date().toISOString(), + workspace: { + id: workspaceData.id, + name: workspaceData.name, }, - variables, + workflows: workflowExports, + folders: folderExports, } - workflowExports.push({ - workflow: { - id: wf.id, - name: wf.name, - description: wf.description, - color: wf.color, - workspaceId: wf.workspaceId, - folderId: wf.folderId, - }, - state, - }) - } catch (error) { - logger.error(`Failed to load workflow ${wf.id}:`, { error }) + return singleResponse(exportPayload) } - } - const folderExports: FolderExportPayload[] = folders.map((f) => ({ - id: f.id, - name: f.name, - parentId: f.parentId, - })) - - logger.info( - `Admin API: Exporting workspace ${workspaceId} with ${workflowExports.length} workflows and ${folderExports.length} folders` - ) - - if (format === 'json') { - const exportPayload: WorkspaceExportPayload = { - version: '1.0', - exportedAt: new Date().toISOString(), - workspace: { - id: workspaceData.id, - name: workspaceData.name, + const zipWorkflows = workflowExports.map((wf) => ({ + workflow: { + id: wf.workflow.id, + name: wf.workflow.name, + description: wf.workflow.description ?? undefined, + color: wf.workflow.color ?? undefined, + folderId: wf.workflow.folderId, }, - workflows: workflowExports, - folders: folderExports, - } - - return singleResponse(exportPayload) + state: wf.state, + variables: wf.state.variables, + })) + + const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports) + const arrayBuffer = await zipBlob.arrayBuffer() + + const sanitizedName = sanitizePathSegment(workspaceData.name) + const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` + + return new NextResponse(arrayBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': arrayBuffer.byteLength.toString(), + }, + }) + } catch (error) { + logger.error('Admin API: Failed to export workspace', { error, workspaceId }) + return internalErrorResponse('Failed to export workspace') } - - const zipWorkflows = workflowExports.map((wf) => ({ - workflow: { - id: wf.workflow.id, - name: wf.workflow.name, - description: wf.workflow.description ?? undefined, - color: wf.workflow.color ?? undefined, - folderId: wf.workflow.folderId, - }, - state: wf.state, - variables: wf.state.variables, - })) - - const zipBlob = await exportWorkspaceToZip(workspaceData.name, zipWorkflows, folderExports) - const arrayBuffer = await zipBlob.arrayBuffer() - - const sanitizedName = sanitizePathSegment(workspaceData.name) - const filename = `${sanitizedName}-${new Date().toISOString().split('T')[0]}.zip` - - return new NextResponse(arrayBuffer, { - status: 200, - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': arrayBuffer.byteLength.toString(), - }, - }) - } catch (error) { - logger.error('Admin API: Failed to export workspace', { error, workspaceId }) - return internalErrorResponse('Failed to export workspace') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts index 37cdc2b9646..9b59e855611 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts @@ -14,6 +14,7 @@ import { db } from '@sim/db' import { workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses' import { @@ -29,47 +30,49 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + try { + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) - if (!workspaceData) { - return notFoundResponse('Workspace') - } + if (!workspaceData) { + return notFoundResponse('Workspace') + } - const [countResult, folders] = await Promise.all([ - db - .select({ total: count() }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), - db - .select() - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)) - .orderBy(workflowFolder.sortOrder, workflowFolder.name) - .limit(limit) - .offset(offset), - ]) + const [countResult, folders] = await Promise.all([ + db + .select({ total: count() }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)), + db + .select() + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)) + .orderBy(workflowFolder.sortOrder, workflowFolder.name) + .limit(limit) + .offset(offset), + ]) - const total = countResult[0].total - const data: AdminFolder[] = folders.map(toAdminFolder) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminFolder[] = folders.map(toAdminFolder) + const pagination = createPaginationMeta(total, limit, offset) - logger.info( - `Admin API: Listed ${data.length} folders in workspace ${workspaceId} (total: ${total})` - ) + logger.info( + `Admin API: Listed ${data.length} folders in workspace ${workspaceId} (total: ${total})` + ) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list workspace folders', { error, workspaceId }) - return internalErrorResponse('Failed to list folders') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list workspace folders', { error, workspaceId }) + return internalErrorResponse('Failed to list folders') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts index adcc927785d..e8c84320785 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts @@ -29,6 +29,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractWorkflowName, extractWorkflowsFromZip, @@ -63,115 +64,117 @@ interface ParsedWorkflow { folderPath: string[] } -export const POST = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const createFolders = url.searchParams.get('createFolders') !== 'false' - const rootFolderName = url.searchParams.get('rootFolderName') +export const POST = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const createFolders = url.searchParams.get('createFolders') !== 'false' + const rootFolderName = url.searchParams.get('rootFolderName') - try { - const workspaceData = await getWorkspaceWithOwner(workspaceId) + try { + const workspaceData = await getWorkspaceWithOwner(workspaceId) - if (!workspaceData) { - return notFoundResponse('Workspace') - } + if (!workspaceData) { + return notFoundResponse('Workspace') + } - const contentType = request.headers.get('content-type') || '' - let workflowsToImport: ParsedWorkflow[] = [] + const contentType = request.headers.get('content-type') || '' + let workflowsToImport: ParsedWorkflow[] = [] - if (contentType.includes('application/json')) { - const body = (await request.json()) as WorkspaceImportRequest + if (contentType.includes('application/json')) { + const body = (await request.json()) as WorkspaceImportRequest - if (!body.workflows || !Array.isArray(body.workflows)) { - return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }') - } + if (!body.workflows || !Array.isArray(body.workflows)) { + return badRequestResponse('Invalid JSON body. Expected { workflows: [...] }') + } - workflowsToImport = body.workflows.map((w) => ({ - content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content), - name: w.name || 'Imported Workflow', - folderPath: w.folderPath || [], - })) - } else if ( - contentType.includes('application/zip') || - contentType.includes('multipart/form-data') - ) { - let zipBuffer: ArrayBuffer - - if (contentType.includes('multipart/form-data')) { - const formData = await request.formData() - const file = formData.get('file') as File | null - - if (!file) { - return badRequestResponse('No file provided in form data. Use field name "file".') + workflowsToImport = body.workflows.map((w) => ({ + content: typeof w.content === 'string' ? w.content : JSON.stringify(w.content), + name: w.name || 'Imported Workflow', + folderPath: w.folderPath || [], + })) + } else if ( + contentType.includes('application/zip') || + contentType.includes('multipart/form-data') + ) { + let zipBuffer: ArrayBuffer + + if (contentType.includes('multipart/form-data')) { + const formData = await request.formData() + const file = formData.get('file') as File | null + + if (!file) { + return badRequestResponse('No file provided in form data. Use field name "file".') + } + + zipBuffer = await file.arrayBuffer() + } else { + zipBuffer = await request.arrayBuffer() } - zipBuffer = await file.arrayBuffer() + const blob = new Blob([zipBuffer], { type: 'application/zip' }) + const file = new File([blob], 'import.zip', { type: 'application/zip' }) + + const { workflows } = await extractWorkflowsFromZip(file) + workflowsToImport = workflows } else { - zipBuffer = await request.arrayBuffer() + return badRequestResponse( + 'Unsupported Content-Type. Use application/json or application/zip.' + ) } - const blob = new Blob([zipBuffer], { type: 'application/zip' }) - const file = new File([blob], 'import.zip', { type: 'application/zip' }) - - const { workflows } = await extractWorkflowsFromZip(file) - workflowsToImport = workflows - } else { - return badRequestResponse( - 'Unsupported Content-Type. Use application/json or application/zip.' - ) - } - - if (workflowsToImport.length === 0) { - return badRequestResponse('No workflows found to import') - } + if (workflowsToImport.length === 0) { + return badRequestResponse('No workflows found to import') + } - let rootFolderId: string | undefined - if (rootFolderName && createFolders) { - rootFolderId = generateId() - await db.insert(workflowFolder).values({ - id: rootFolderId, - name: rootFolderName, - userId: workspaceData.ownerId, - workspaceId, - parentId: null, - createdAt: new Date(), - updatedAt: new Date(), - }) - } + let rootFolderId: string | undefined + if (rootFolderName && createFolders) { + rootFolderId = generateId() + await db.insert(workflowFolder).values({ + id: rootFolderId, + name: rootFolderName, + userId: workspaceData.ownerId, + workspaceId, + parentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }) + } - const folderMap = new Map() - const results: ImportResult[] = [] - - for (const wf of workflowsToImport) { - const result = await importSingleWorkflow( - wf, - workspaceId, - workspaceData.ownerId, - createFolders, - rootFolderId, - folderMap - ) - results.push(result) - - if (result.success) { - logger.info(`Admin API: Imported workflow ${result.workflowId} (${result.name})`) - } else { - logger.warn(`Admin API: Failed to import workflow ${result.name}: ${result.error}`) + const folderMap = new Map() + const results: ImportResult[] = [] + + for (const wf of workflowsToImport) { + const result = await importSingleWorkflow( + wf, + workspaceId, + workspaceData.ownerId, + createFolders, + rootFolderId, + folderMap + ) + results.push(result) + + if (result.success) { + logger.info(`Admin API: Imported workflow ${result.workflowId} (${result.name})`) + } else { + logger.warn(`Admin API: Failed to import workflow ${result.name}: ${result.error}`) + } } - } - const imported = results.filter((r) => r.success).length - const failed = results.filter((r) => !r.success).length + const imported = results.filter((r) => r.success).length + const failed = results.filter((r) => !r.success).length - logger.info(`Admin API: Import complete - ${imported} succeeded, ${failed} failed`) + logger.info(`Admin API: Import complete - ${imported} succeeded, ${failed} failed`) - const response: WorkspaceImportResponse = { imported, failed, results } - return NextResponse.json(response) - } catch (error) { - logger.error('Admin API: Failed to import into workspace', { error, workspaceId }) - return internalErrorResponse('Failed to import workflows') - } -}) + const response: WorkspaceImportResponse = { imported, failed, results } + return NextResponse.json(response) + } catch (error) { + logger.error('Admin API: Failed to import into workspace', { error, workspaceId }) + return internalErrorResponse('Failed to import workflows') + } + }) +) async function importSingleWorkflow( wf: ParsedWorkflow, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts index 07da5734245..dd8c005395b 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/[memberId]/route.ts @@ -25,6 +25,7 @@ import { db } from '@sim/db' import { permissions, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -43,182 +44,188 @@ interface RouteParams { memberId: string } -export const GET = withAdminAuthParams(async (_, context) => { - const { id: workspaceId, memberId } = await context.params - - try { - const workspaceData = await getWorkspaceById(workspaceId) - - if (!workspaceData) { - return notFoundResponse('Workspace') - } - - const [memberData] = await db - .select({ - id: permissions.id, - userId: permissions.userId, - permissionType: permissions.permissionType, - createdAt: permissions.createdAt, - updatedAt: permissions.updatedAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .where( - and( - eq(permissions.id, memberId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) +export const GET = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: workspaceId, memberId } = await context.params + + try { + const workspaceData = await getWorkspaceById(workspaceId) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [memberData] = await db + .select({ + id: permissions.id, + userId: permissions.userId, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + updatedAt: permissions.updatedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where( + and( + eq(permissions.id, memberId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - ) - .limit(1) - - if (!memberData) { - return notFoundResponse('Workspace member') - } - - const data: AdminWorkspaceMember = { - id: memberData.id, - workspaceId, - userId: memberData.userId, - permissions: memberData.permissionType, - createdAt: memberData.createdAt.toISOString(), - updatedAt: memberData.updatedAt.toISOString(), - userName: memberData.userName, - userEmail: memberData.userEmail, - userImage: memberData.userImage, + .limit(1) + + if (!memberData) { + return notFoundResponse('Workspace member') + } + + const data: AdminWorkspaceMember = { + id: memberData.id, + workspaceId, + userId: memberData.userId, + permissions: memberData.permissionType, + createdAt: memberData.createdAt.toISOString(), + updatedAt: memberData.updatedAt.toISOString(), + userName: memberData.userName, + userEmail: memberData.userEmail, + userImage: memberData.userImage, + } + + logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`) + + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId }) + return internalErrorResponse('Failed to get workspace member') } - - logger.info(`Admin API: Retrieved member ${memberId} from workspace ${workspaceId}`) - - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get workspace member', { error, workspaceId, memberId }) - return internalErrorResponse('Failed to get workspace member') - } -}) - -export const PATCH = withAdminAuthParams(async (request, context) => { - const { id: workspaceId, memberId } = await context.params - - try { - const body = await request.json() - - if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { - return badRequestResponse('permissions must be "admin", "write", or "read"') - } - - const workspaceData = await getWorkspaceById(workspaceId) - - if (!workspaceData) { - return notFoundResponse('Workspace') - } - - const [existingMember] = await db - .select({ - id: permissions.id, - userId: permissions.userId, - permissionType: permissions.permissionType, - createdAt: permissions.createdAt, - }) - .from(permissions) - .where( - and( - eq(permissions.id, memberId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + }) +) + +export const PATCH = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId, memberId } = await context.params + + try { + const body = await request.json() + + if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { + return badRequestResponse('permissions must be "admin", "write", or "read"') + } + + const workspaceData = await getWorkspaceById(workspaceId) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [existingMember] = await db + .select({ + id: permissions.id, + userId: permissions.userId, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + }) + .from(permissions) + .where( + and( + eq(permissions.id, memberId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - ) - .limit(1) - - if (!existingMember) { - return notFoundResponse('Workspace member') - } + .limit(1) + + if (!existingMember) { + return notFoundResponse('Workspace member') + } + + const now = new Date() + + await db + .update(permissions) + .set({ permissionType: body.permissions, updatedAt: now }) + .where(eq(permissions.id, memberId)) + + const [userData] = await db + .select({ name: user.name, email: user.email, image: user.image }) + .from(user) + .where(eq(user.id, existingMember.userId)) + .limit(1) + + const data: AdminWorkspaceMember = { + id: existingMember.id, + workspaceId, + userId: existingMember.userId, + permissions: body.permissions, + createdAt: existingMember.createdAt.toISOString(), + updatedAt: now.toISOString(), + userName: userData?.name ?? '', + userEmail: userData?.email ?? '', + userImage: userData?.image ?? null, + } + + logger.info(`Admin API: Updated member ${memberId} permissions to ${body.permissions}`, { + workspaceId, + previousPermissions: existingMember.permissionType, + }) - const now = new Date() - - await db - .update(permissions) - .set({ permissionType: body.permissions, updatedAt: now }) - .where(eq(permissions.id, memberId)) - - const [userData] = await db - .select({ name: user.name, email: user.email, image: user.image }) - .from(user) - .where(eq(user.id, existingMember.userId)) - .limit(1) - - const data: AdminWorkspaceMember = { - id: existingMember.id, - workspaceId, - userId: existingMember.userId, - permissions: body.permissions, - createdAt: existingMember.createdAt.toISOString(), - updatedAt: now.toISOString(), - userName: userData?.name ?? '', - userEmail: userData?.email ?? '', - userImage: userData?.image ?? null, + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId }) + return internalErrorResponse('Failed to update workspace member') } + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (_, context) => { + const { id: workspaceId, memberId } = await context.params + + try { + const workspaceData = await getWorkspaceById(workspaceId) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [existingMember] = await db + .select({ + id: permissions.id, + userId: permissions.userId, + }) + .from(permissions) + .where( + and( + eq(permissions.id, memberId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .limit(1) - logger.info(`Admin API: Updated member ${memberId} permissions to ${body.permissions}`, { - workspaceId, - previousPermissions: existingMember.permissionType, - }) - - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to update workspace member', { error, workspaceId, memberId }) - return internalErrorResponse('Failed to update workspace member') - } -}) - -export const DELETE = withAdminAuthParams(async (_, context) => { - const { id: workspaceId, memberId } = await context.params + if (!existingMember) { + return notFoundResponse('Workspace member') + } - try { - const workspaceData = await getWorkspaceById(workspaceId) + await db.delete(permissions).where(eq(permissions.id, memberId)) - if (!workspaceData) { - return notFoundResponse('Workspace') - } + await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId) - const [existingMember] = await db - .select({ - id: permissions.id, - userId: permissions.userId, + logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { + userId: existingMember.userId, }) - .from(permissions) - .where( - and( - eq(permissions.id, memberId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .limit(1) - if (!existingMember) { - return notFoundResponse('Workspace member') + return singleResponse({ + removed: true, + memberId, + userId: existingMember.userId, + workspaceId, + }) + } catch (error) { + logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId }) + return internalErrorResponse('Failed to remove workspace member') } - - await db.delete(permissions).where(eq(permissions.id, memberId)) - - await revokeWorkspaceCredentialMemberships(workspaceId, existingMember.userId) - - logger.info(`Admin API: Removed member ${memberId} from workspace ${workspaceId}`, { - userId: existingMember.userId, - }) - - return singleResponse({ - removed: true, - memberId, - userId: existingMember.userId, - workspaceId, - }) - } catch (error) { - logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, memberId }) - return internalErrorResponse('Failed to remove workspace member') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts index d9a399268fd..64955797e49 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/members/route.ts @@ -35,6 +35,7 @@ import { permissions, user, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq } from 'drizzle-orm' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' @@ -57,246 +58,256 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const workspaceData = await getWorkspaceById(workspaceId) + try { + const workspaceData = await getWorkspaceById(workspaceId) - if (!workspaceData) { - return notFoundResponse('Workspace') + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [countResult, membersData] = await Promise.all([ + db + .select({ count: count() }) + .from(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)) + ), + db + .select({ + id: permissions.id, + userId: permissions.userId, + permissionType: permissions.permissionType, + createdAt: permissions.createdAt, + updatedAt: permissions.updatedAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where( + and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)) + ) + .orderBy(permissions.createdAt) + .limit(limit) + .offset(offset), + ]) + + const total = countResult[0].count + const data: AdminWorkspaceMember[] = membersData.map((m) => ({ + id: m.id, + workspaceId, + userId: m.userId, + permissions: m.permissionType, + createdAt: m.createdAt.toISOString(), + updatedAt: m.updatedAt.toISOString(), + userName: m.userName, + userEmail: m.userEmail, + userImage: m.userImage, + })) + + const pagination = createPaginationMeta(total, limit, offset) + + logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`) + + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list workspace members', { error, workspaceId }) + return internalErrorResponse('Failed to list workspace members') } + }) +) - const [countResult, membersData] = await Promise.all([ - db - .select({ count: count() }) - .from(permissions) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), - db +export const POST = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + + try { + const body = await request.json() + + if (!body.userId || typeof body.userId !== 'string') { + return badRequestResponse('userId is required') + } + + if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { + return badRequestResponse('permissions must be "admin", "write", or "read"') + } + + const workspaceData = await getWorkspaceById(workspaceId) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [userData] = await db + .select({ id: user.id, name: user.name, email: user.email, image: user.image }) + .from(user) + .where(eq(user.id, body.userId)) + .limit(1) + + if (!userData) { + return notFoundResponse('User') + } + + const [existingPermission] = await db .select({ id: permissions.id, - userId: permissions.userId, permissionType: permissions.permissionType, createdAt: permissions.createdAt, updatedAt: permissions.updatedAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, }) .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - .orderBy(permissions.createdAt) - .limit(limit) - .offset(offset), - ]) - - const total = countResult[0].count - const data: AdminWorkspaceMember[] = membersData.map((m) => ({ - id: m.id, - workspaceId, - userId: m.userId, - permissions: m.permissionType, - createdAt: m.createdAt.toISOString(), - updatedAt: m.updatedAt.toISOString(), - userName: m.userName, - userEmail: m.userEmail, - userImage: m.userImage, - })) - - const pagination = createPaginationMeta(total, limit, offset) - - logger.info(`Admin API: Listed ${data.length} members for workspace ${workspaceId}`) - - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list workspace members', { error, workspaceId }) - return internalErrorResponse('Failed to list workspace members') - } -}) - -export const POST = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - - try { - const body = await request.json() - - if (!body.userId || typeof body.userId !== 'string') { - return badRequestResponse('userId is required') - } - - if (!body.permissions || !['admin', 'write', 'read'].includes(body.permissions)) { - return badRequestResponse('permissions must be "admin", "write", or "read"') - } - - const workspaceData = await getWorkspaceById(workspaceId) - - if (!workspaceData) { - return notFoundResponse('Workspace') - } - - const [userData] = await db - .select({ id: user.id, name: user.name, email: user.email, image: user.image }) - .from(user) - .where(eq(user.id, body.userId)) - .limit(1) - - if (!userData) { - return notFoundResponse('User') - } - - const [existingPermission] = await db - .select({ - id: permissions.id, - permissionType: permissions.permissionType, - createdAt: permissions.createdAt, - updatedAt: permissions.updatedAt, - }) - .from(permissions) - .where( - and( - eq(permissions.userId, body.userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .limit(1) - - if (existingPermission) { - if (existingPermission.permissionType !== body.permissions) { - const now = new Date() - await db - .update(permissions) - .set({ permissionType: body.permissions, updatedAt: now }) - .where(eq(permissions.id, existingPermission.id)) - - logger.info( - `Admin API: Updated user ${body.userId} permissions in workspace ${workspaceId}`, - { - previousPermissions: existingPermission.permissionType, - newPermissions: body.permissions, - } + .where( + and( + eq(permissions.userId, body.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) + .limit(1) + + if (existingPermission) { + if (existingPermission.permissionType !== body.permissions) { + const now = new Date() + await db + .update(permissions) + .set({ permissionType: body.permissions, updatedAt: now }) + .where(eq(permissions.id, existingPermission.id)) + + logger.info( + `Admin API: Updated user ${body.userId} permissions in workspace ${workspaceId}`, + { + previousPermissions: existingPermission.permissionType, + newPermissions: body.permissions, + } + ) + + return singleResponse({ + id: existingPermission.id, + workspaceId, + userId: body.userId, + permissions: body.permissions as 'admin' | 'write' | 'read', + createdAt: existingPermission.createdAt.toISOString(), + updatedAt: now.toISOString(), + userName: userData.name, + userEmail: userData.email, + userImage: userData.image, + action: 'updated' as const, + }) + } return singleResponse({ id: existingPermission.id, workspaceId, userId: body.userId, - permissions: body.permissions as 'admin' | 'write' | 'read', + permissions: existingPermission.permissionType, createdAt: existingPermission.createdAt.toISOString(), - updatedAt: now.toISOString(), + updatedAt: existingPermission.updatedAt.toISOString(), userName: userData.name, userEmail: userData.email, userImage: userData.image, - action: 'updated' as const, + action: 'already_member' as const, + }) + } + + const now = new Date() + const permissionId = generateId() + + await db.insert(permissions).values({ + id: permissionId, + userId: body.userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: body.permissions, + createdAt: now, + updatedAt: now, + }) + + logger.info(`Admin API: Added user ${body.userId} to workspace ${workspaceId}`, { + permissions: body.permissions, + permissionId, + }) + + const [wsEnvRow] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: wsEnvKeys, + actingUserId: body.userId, }) } return singleResponse({ - id: existingPermission.id, + id: permissionId, workspaceId, userId: body.userId, - permissions: existingPermission.permissionType, - createdAt: existingPermission.createdAt.toISOString(), - updatedAt: existingPermission.updatedAt.toISOString(), + permissions: body.permissions as 'admin' | 'write' | 'read', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), userName: userData.name, userEmail: userData.email, userImage: userData.image, - action: 'already_member' as const, + action: 'created' as const, }) + } catch (error) { + logger.error('Admin API: Failed to add workspace member', { error, workspaceId }) + return internalErrorResponse('Failed to add workspace member') } + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const userId = url.searchParams.get('userId') + + try { + if (!userId) { + return badRequestResponse('userId query parameter is required') + } - const now = new Date() - const permissionId = generateId() - - await db.insert(permissions).values({ - id: permissionId, - userId: body.userId, - entityType: 'workspace', - entityId: workspaceId, - permissionType: body.permissions, - createdAt: now, - updatedAt: now, - }) - - logger.info(`Admin API: Added user ${body.userId} to workspace ${workspaceId}`, { - permissions: body.permissions, - permissionId, - }) - - const [wsEnvRow] = await db - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) - if (wsEnvKeys.length > 0) { - await syncWorkspaceEnvCredentials({ - workspaceId, - envKeys: wsEnvKeys, - actingUserId: body.userId, - }) - } - - return singleResponse({ - id: permissionId, - workspaceId, - userId: body.userId, - permissions: body.permissions as 'admin' | 'write' | 'read', - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - userName: userData.name, - userEmail: userData.email, - userImage: userData.image, - action: 'created' as const, - }) - } catch (error) { - logger.error('Admin API: Failed to add workspace member', { error, workspaceId }) - return internalErrorResponse('Failed to add workspace member') - } -}) - -export const DELETE = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const userId = url.searchParams.get('userId') - - try { - if (!userId) { - return badRequestResponse('userId query parameter is required') - } - - const workspaceData = await getWorkspaceById(workspaceId) + const workspaceData = await getWorkspaceById(workspaceId) - if (!workspaceData) { - return notFoundResponse('Workspace') - } + if (!workspaceData) { + return notFoundResponse('Workspace') + } - const [existingPermission] = await db - .select({ id: permissions.id }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + const [existingPermission] = await db + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - ) - .limit(1) + .limit(1) - if (!existingPermission) { - return notFoundResponse('Workspace member') - } + if (!existingPermission) { + return notFoundResponse('Workspace member') + } - await db.delete(permissions).where(eq(permissions.id, existingPermission.id)) + await db.delete(permissions).where(eq(permissions.id, existingPermission.id)) - logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`) + logger.info(`Admin API: Removed user ${userId} from workspace ${workspaceId}`) - return singleResponse({ removed: true, userId, workspaceId }) - } catch (error) { - logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, userId }) - return internalErrorResponse('Failed to remove workspace member') - } -}) + return singleResponse({ removed: true, userId, workspaceId }) + } catch (error) { + logger.error('Admin API: Failed to remove workspace member', { error, workspaceId, userId }) + return internalErrorResponse('Failed to remove workspace member') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts index ee34556fc6a..3635d1c9b51 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/route.ts @@ -10,6 +10,7 @@ import { db } from '@sim/db' import { workflow, workflowFolder, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -24,39 +25,41 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params - try { - const [workspaceData] = await db - .select() - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + try { + const [workspaceData] = await db + .select() + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) - if (!workspaceData) { - return notFoundResponse('Workspace') - } + if (!workspaceData) { + return notFoundResponse('Workspace') + } - const [workflowCountResult, folderCountResult] = await Promise.all([ - db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)), - db - .select({ count: count() }) - .from(workflowFolder) - .where(eq(workflowFolder.workspaceId, workspaceId)), - ]) - - const data: AdminWorkspaceDetail = { - ...toAdminWorkspace(workspaceData), - workflowCount: workflowCountResult[0].count, - folderCount: folderCountResult[0].count, - } + const [workflowCountResult, folderCountResult] = await Promise.all([ + db.select({ count: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)), + db + .select({ count: count() }) + .from(workflowFolder) + .where(eq(workflowFolder.workspaceId, workspaceId)), + ]) - logger.info(`Admin API: Retrieved workspace ${workspaceId}`) + const data: AdminWorkspaceDetail = { + ...toAdminWorkspace(workspaceData), + workflowCount: workflowCountResult[0].count, + folderCount: folderCountResult[0].count, + } - return singleResponse(data) - } catch (error) { - logger.error('Admin API: Failed to get workspace', { error, workspaceId }) - return internalErrorResponse('Failed to get workspace') - } -}) + logger.info(`Admin API: Retrieved workspace ${workspaceId}`) + + return singleResponse(data) + } catch (error) { + logger.error('Admin API: Failed to get workspace', { error, workspaceId }) + return internalErrorResponse('Failed to get workspace') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts index 896af40d6a7..89fba14b1dc 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/workflows/route.ts @@ -21,6 +21,7 @@ import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses' @@ -37,83 +38,87 @@ interface RouteParams { id: string } -export const GET = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) - - try { - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - - if (!workspaceData) { - return notFoundResponse('Workspace') +export const GET = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + + try { + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const [countResult, workflows] = await Promise.all([ + db + .select({ total: count() }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), + db + .select() + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) + .orderBy(workflow.name) + .limit(limit) + .offset(offset), + ]) + + const total = countResult[0].total + const data: AdminWorkflow[] = workflows.map(toAdminWorkflow) + const pagination = createPaginationMeta(total, limit, offset) + + logger.info( + `Admin API: Listed ${data.length} workflows in workspace ${workspaceId} (total: ${total})` + ) + + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list workspace workflows', { error, workspaceId }) + return internalErrorResponse('Failed to list workflows') } - - const [countResult, workflows] = await Promise.all([ - db - .select({ total: count() }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), - db - .select() + }) +) + +export const DELETE = withRouteHandler( + withAdminAuthParams(async (request, context) => { + const { id: workspaceId } = await context.params + + try { + const [workspaceData] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!workspaceData) { + return notFoundResponse('Workspace') + } + + const workflowsToDelete = await db + .select({ id: workflow.id }) .from(workflow) .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) - .orderBy(workflow.name) - .limit(limit) - .offset(offset), - ]) - - const total = countResult[0].total - const data: AdminWorkflow[] = workflows.map(toAdminWorkflow) - const pagination = createPaginationMeta(total, limit, offset) - - logger.info( - `Admin API: Listed ${data.length} workflows in workspace ${workspaceId} (total: ${total})` - ) - - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list workspace workflows', { error, workspaceId }) - return internalErrorResponse('Failed to list workflows') - } -}) - -export const DELETE = withAdminAuthParams(async (request, context) => { - const { id: workspaceId } = await context.params - - try { - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - - if (!workspaceData) { - return notFoundResponse('Workspace') - } - - const workflowsToDelete = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) - if (workflowsToDelete.length === 0) { - return NextResponse.json({ success: true, deleted: 0 }) - } + if (workflowsToDelete.length === 0) { + return NextResponse.json({ success: true, deleted: 0 }) + } - const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, { - requestId: `admin-workspace-${workspaceId}`, - }) + const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, { + requestId: `admin-workspace-${workspaceId}`, + }) - logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`) + logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`) - return NextResponse.json({ success: true, deleted: deletedCount }) - } catch (error) { - logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId }) - return internalErrorResponse('Failed to delete workflows') - } -}) + return NextResponse.json({ success: true, deleted: deletedCount }) + } catch (error) { + logger.error('Admin API: Failed to delete workspace workflows', { error, workspaceId }) + return internalErrorResponse('Failed to delete workflows') + } + }) +) diff --git a/apps/sim/app/api/v1/admin/workspaces/route.ts b/apps/sim/app/api/v1/admin/workspaces/route.ts index 0724770cedc..7446fceba8b 100644 --- a/apps/sim/app/api/v1/admin/workspaces/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/route.ts @@ -14,6 +14,7 @@ import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse } from '@/app/api/v1/admin/responses' import { @@ -25,25 +26,27 @@ import { const logger = createLogger('AdminWorkspacesAPI') -export const GET = withAdminAuth(async (request) => { - const url = new URL(request.url) - const { limit, offset } = parsePaginationParams(url) +export const GET = withRouteHandler( + withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) - try { - const [countResult, workspaces] = await Promise.all([ - db.select({ total: count() }).from(workspace), - db.select().from(workspace).orderBy(workspace.name).limit(limit).offset(offset), - ]) + try { + const [countResult, workspaces] = await Promise.all([ + db.select({ total: count() }).from(workspace), + db.select().from(workspace).orderBy(workspace.name).limit(limit).offset(offset), + ]) - const total = countResult[0].total - const data: AdminWorkspace[] = workspaces.map(toAdminWorkspace) - const pagination = createPaginationMeta(total, limit, offset) + const total = countResult[0].total + const data: AdminWorkspace[] = workspaces.map(toAdminWorkspace) + const pagination = createPaginationMeta(total, limit, offset) - logger.info(`Admin API: Listed ${data.length} workspaces (total: ${total})`) + logger.info(`Admin API: Listed ${data.length} workspaces (total: ${total})`) - return listResponse(data, pagination) - } catch (error) { - logger.error('Admin API: Failed to list workspaces', { error }) - return internalErrorResponse('Failed to list workspaces') - } -}) + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list workspaces', { error }) + return internalErrorResponse('Failed to list workspaces') + } + }) +) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 948a034afc0..d04df09bcfb 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' @@ -25,55 +26,57 @@ const logger = createLogger('V1AuditLogDetailAPI') export const revalidate = 0 -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) - try { - const rateLimit = await checkRateLimit(request, 'audit-logs') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const userId = rateLimit.userId! - const { id } = await params + const userId = rateLimit.userId! + const { id } = await params - const authResult = await validateEnterpriseAuditAccess(userId) - if (!authResult.success) { - return authResult.response - } + const authResult = await validateEnterpriseAuditAccess(userId) + if (!authResult.success) { + return authResult.response + } - const { orgMemberIds } = authResult.context + const { orgMemberIds } = authResult.context - const orgWorkspaceIds = db - .select({ id: workspace.id }) - .from(workspace) - .where(inArray(workspace.ownerId, orgMemberIds)) + const orgWorkspaceIds = db + .select({ id: workspace.id }) + .from(workspace) + .where(inArray(workspace.ownerId, orgMemberIds)) - const [log] = await db - .select() - .from(auditLog) - .where( - and( - eq(auditLog.id, id), - or( - inArray(auditLog.actorId, orgMemberIds), - inArray(auditLog.workspaceId, orgWorkspaceIds) + const [log] = await db + .select() + .from(auditLog) + .where( + and( + eq(auditLog.id, id), + or( + inArray(auditLog.actorId, orgMemberIds), + inArray(auditLog.workspaceId, orgWorkspaceIds) + ) ) ) - ) - .limit(1) + .limit(1) - if (!log) { - return NextResponse.json({ error: 'Audit log not found' }, { status: 404 }) - } + if (!log) { + return NextResponse.json({ error: 'Audit log not found' }, { status: 404 }) + } - const limits = await getUserLimits(userId) - const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit) + const limits = await getUserLimits(userId) + const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit) - return NextResponse.json(response.body, { headers: response.headers }) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] Audit log detail fetch error`, { error: message }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Audit log detail fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 5a090391da4..8079961dbb1 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -26,6 +26,7 @@ import { and, desc, eq, gte, inArray, lt, lte, or, type SQL } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' @@ -74,7 +75,7 @@ function decodeCursor(cursor: string): CursorData | null { } } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -189,4 +190,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Audit logs fetch error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 52be435d681..ef31dd704ba 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -5,6 +5,7 @@ import { createRunSegment } from '@/lib/copilot/async-runs/repository' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { authenticateV1Request } from '@/app/api/v1/auth' @@ -33,7 +34,7 @@ const RequestSchema = z.object({ * - Otherwise uses the user's first workflow as context * - The copilot can still operate on any workflow using list_user_workflows */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { let messageId: string | undefined const auth = await authenticateV1Request(req) if (!auth.authenticated || !auth.userId) { @@ -147,4 +148,4 @@ export async function POST(req: NextRequest) { }) return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 7007053681b..737f8217307 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteWorkspaceFile, downloadWorkspaceFile, @@ -29,7 +30,7 @@ interface FileRouteParams { } /** GET /api/v1/files/[fileId] — Download file content. */ -export async function GET(request: NextRequest, { params }: FileRouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: FileRouteParams) => { const requestId = generateRequestId() try { @@ -87,72 +88,74 @@ export async function GET(request: NextRequest, { params }: FileRouteParams) { logger.error(`[${requestId}] Error downloading file:`, error) return NextResponse.json({ error: 'Failed to download file' }, { status: 500 }) } -} +}) /** DELETE /api/v1/files/[fileId] — Archive a file. */ -export async function DELETE(request: NextRequest, { params }: FileRouteParams) { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { fileId } = await params - const { searchParams } = new URL(request.url) - - const validation = WorkspaceIdSchema.safeParse({ - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) { - return NextResponse.json( - { error: 'Validation error', details: validation.error.errors }, - { status: 400 } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: FileRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { fileId } = await params + const { searchParams } = new URL(request.url) + + const validation = WorkspaceIdSchema.safeParse({ + workspaceId: searchParams.get('workspaceId'), + }) + if (!validation.success) { + return NextResponse.json( + { error: 'Validation error', details: validation.error.errors }, + { status: 400 } + ) + } + + const { workspaceId } = validation.data + + const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return scopeError + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission === null || permission === 'read') { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + await deleteWorkspaceFile(workspaceId, fileId) + + logger.info( + `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` ) - } - - const { workspaceId } = validation.data - - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission === null || permission === 'read') { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: fileRecord.name, + description: `Archived file "${fileRecord.name}" via API`, + request, + }) + + return NextResponse.json({ + success: true, + data: { + message: 'File archived successfully', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting file:`, error) + return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) } - - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info( - `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` - ) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Archived file "${fileRecord.name}" via API`, - request, - }) - - return NextResponse.json({ - success: true, - data: { - message: 'File archived successfully', - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting file:`, error) - return NextResponse.json({ error: 'Failed to delete file' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 8c344c1575d..79b85c02e4c 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileConflictError, getWorkspaceFile, @@ -28,7 +29,7 @@ const ListFilesSchema = z.object({ }) /** GET /api/v1/files — List all files in a workspace. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -82,10 +83,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error listing files:`, error) return NextResponse.json({ error: 'Failed to list files' }, { status: 500 }) } -} +}) /** POST /api/v1/files — Upload a file to a workspace. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -193,4 +194,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error uploading file:`, error) return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index b69721329a4..3ab5f41bbcc 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -4,6 +4,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteDocument } from '@/lib/knowledge/documents/service' import { authenticateRequest, @@ -25,158 +26,162 @@ const WorkspaceIdSchema = z.object({ }) /** GET /api/v1/knowledge/[id]/documents/[documentId] — Get document details. */ -export async function GET(request: NextRequest, { params }: DocumentDetailRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id: knowledgeBaseId, documentId } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response - - const result = await resolveKnowledgeBase( - knowledgeBaseId, - validation.data.workspaceId, - userId, - rateLimit - ) - if (result instanceof NextResponse) return result - - const docs = await db - .select({ - id: document.id, - knowledgeBaseId: document.knowledgeBaseId, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - processingStatus: document.processingStatus, - processingError: document.processingError, - processingStartedAt: document.processingStartedAt, - processingCompletedAt: document.processingCompletedAt, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - characterCount: document.characterCount, - enabled: document.enabled, - uploadedAt: document.uploadedAt, - connectorId: document.connectorId, - connectorType: knowledgeConnector.connectorType, - sourceUrl: document.sourceUrl, +export const GET = withRouteHandler( + async (request: NextRequest, { params }: DocumentDetailRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const { id: knowledgeBaseId, documentId } = await params + const { searchParams } = new URL(request.url) + + const validation = validateSchema(WorkspaceIdSchema, { + workspaceId: searchParams.get('workspaceId'), }) - .from(document) - .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) - ) - .limit(1) + if (!validation.success) return validation.response - if (docs.length === 0) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - const doc = docs[0] - - return NextResponse.json({ - success: true, - data: { - document: { - id: doc.id, - knowledgeBaseId: doc.knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - processingError: doc.processingError, - processingStartedAt: serializeDate(doc.processingStartedAt), - processingCompletedAt: serializeDate(doc.processingCompletedAt), - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - connectorId: doc.connectorId, - connectorType: doc.connectorType, - sourceUrl: doc.sourceUrl, - createdAt: serializeDate(doc.uploadedAt), + const result = await resolveKnowledgeBase( + knowledgeBaseId, + validation.data.workspaceId, + userId, + rateLimit + ) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ + id: document.id, + knowledgeBaseId: document.knowledgeBaseId, + filename: document.filename, + fileSize: document.fileSize, + mimeType: document.mimeType, + processingStatus: document.processingStatus, + processingError: document.processingError, + processingStartedAt: document.processingStartedAt, + processingCompletedAt: document.processingCompletedAt, + chunkCount: document.chunkCount, + tokenCount: document.tokenCount, + characterCount: document.characterCount, + enabled: document.enabled, + uploadedAt: document.uploadedAt, + connectorId: document.connectorId, + connectorType: knowledgeConnector.connectorType, + sourceUrl: document.sourceUrl, + }) + .from(document) + .leftJoin(knowledgeConnector, eq(document.connectorId, knowledgeConnector.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + if (docs.length === 0) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + const doc = docs[0] + + return NextResponse.json({ + success: true, + data: { + document: { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), + }, }, - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to get document') + }) + } catch (error) { + return handleError(requestId, error, 'Failed to get document') + } } -} +) /** DELETE /api/v1/knowledge/[id]/documents/[documentId] — Delete a document. */ -export async function DELETE(request: NextRequest, { params }: DocumentDetailRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id: knowledgeBaseId, documentId } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response - - const result = await resolveKnowledgeBase( - knowledgeBaseId, - validation.data.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const docs = await db - .select({ id: document.id, filename: document.filename }) - .from(document) - .where( - and( - eq(document.id, documentId), - eq(document.knowledgeBaseId, knowledgeBaseId), - eq(document.userExcluded, false), - isNull(document.archivedAt), - isNull(document.deletedAt) - ) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: DocumentDetailRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const { id: knowledgeBaseId, documentId } = await params + const { searchParams } = new URL(request.url) + + const validation = validateSchema(WorkspaceIdSchema, { + workspaceId: searchParams.get('workspaceId'), + }) + if (!validation.success) return validation.response + + const result = await resolveKnowledgeBase( + knowledgeBaseId, + validation.data.workspaceId, + userId, + rateLimit, + 'write' ) - .limit(1) + if (result instanceof NextResponse) return result + + const docs = await db + .select({ id: document.id, filename: document.filename }) + .from(document) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .limit(1) + + if (docs.length === 0) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + await deleteDocument(documentId, requestId) + + recordAudit({ + workspaceId: validation.data.workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: docs[0].filename, + description: `Deleted document "${docs[0].filename}" from knowledge base via API`, + request, + }) - if (docs.length === 0) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + return NextResponse.json({ + success: true, + data: { + message: 'Document deleted successfully', + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to delete document') } - - await deleteDocument(documentId, requestId) - - recordAudit({ - workspaceId: validation.data.workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_DELETED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: documentId, - resourceName: docs[0].filename, - description: `Deleted document "${docs[0].filename}" from knowledge base via API`, - request, - }) - - return NextResponse.json({ - success: true, - data: { - message: 'Document deleted successfully', - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to delete document') } -} +) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 7310a4eca98..e18de44a5d8 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createSingleDocument, type DocumentData, @@ -48,188 +49,193 @@ const ListDocumentsSchema = z.object({ }) /** GET /api/v1/knowledge/[id]/documents — List documents in a knowledge base. */ -export async function GET(request: NextRequest, { params }: DocumentsRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id: knowledgeBaseId } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(ListDocumentsSchema, { - workspaceId: searchParams.get('workspaceId'), - limit: searchParams.get('limit') ?? undefined, - offset: searchParams.get('offset') ?? undefined, - search: searchParams.get('search') ?? undefined, - enabledFilter: searchParams.get('enabledFilter') ?? undefined, - sortBy: searchParams.get('sortBy') ?? undefined, - sortOrder: searchParams.get('sortOrder') ?? undefined, - }) - if (!validation.success) return validation.response - - const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } = validation.data - - const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit) - if (result instanceof NextResponse) return result - - const documentsResult = await getDocuments( - knowledgeBaseId, - { - enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, - search, - limit, - offset, - sortBy: sortBy as DocumentSortField, - sortOrder: sortOrder as SortOrder, - }, - requestId - ) - - return NextResponse.json({ - success: true, - data: { - documents: documentsResult.documents.map((doc) => ({ - id: doc.id, - knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus, - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - createdAt: serializeDate(doc.uploadedAt), - })), - pagination: documentsResult.pagination, - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to list documents') +export const GET = withRouteHandler( + async (request: NextRequest, { params }: DocumentsRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const { id: knowledgeBaseId } = await params + const { searchParams } = new URL(request.url) + + const validation = validateSchema(ListDocumentsSchema, { + workspaceId: searchParams.get('workspaceId'), + limit: searchParams.get('limit') ?? undefined, + offset: searchParams.get('offset') ?? undefined, + search: searchParams.get('search') ?? undefined, + enabledFilter: searchParams.get('enabledFilter') ?? undefined, + sortBy: searchParams.get('sortBy') ?? undefined, + sortOrder: searchParams.get('sortOrder') ?? undefined, + }) + if (!validation.success) return validation.response + + const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } = + validation.data + + const result = await resolveKnowledgeBase(knowledgeBaseId, workspaceId, userId, rateLimit) + if (result instanceof NextResponse) return result + + const documentsResult = await getDocuments( + knowledgeBaseId, + { + enabledFilter: enabledFilter === 'all' ? undefined : enabledFilter, + search, + limit, + offset, + sortBy: sortBy as DocumentSortField, + sortOrder: sortOrder as SortOrder, + }, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + documents: documentsResult.documents.map((doc) => ({ + id: doc.id, + knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus, + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + createdAt: serializeDate(doc.uploadedAt), + })), + pagination: documentsResult.pagination, + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to list documents') + } } -} +) /** POST /api/v1/knowledge/[id]/documents — Upload a document to a knowledge base. */ -export async function POST(request: NextRequest, { params }: DocumentsRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth +export const POST = withRouteHandler( + async (request: NextRequest, { params }: DocumentsRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth - try { - const { id: knowledgeBaseId } = await params - - let formData: FormData try { - formData = await request.formData() - } catch { - return NextResponse.json( - { error: 'Request body must be valid multipart form data' }, - { status: 400 } + const { id: knowledgeBaseId } = await params + + let formData: FormData + try { + formData = await request.formData() + } catch { + return NextResponse.json( + { error: 'Request body must be valid multipart form data' }, + { status: 400 } + ) + } + + const rawFile = formData.get('file') + const file = rawFile instanceof File ? rawFile : null + const rawWorkspaceId = formData.get('workspaceId') + const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null + + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 }) + } + + if (!file) { + return NextResponse.json({ error: 'file form field is required' }, { status: 400 }) + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { + error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`, + }, + { status: 413 } + ) + } + + const fileTypeError = validateFileType(file.name, file.type || '') + if (fileTypeError) { + return NextResponse.json({ error: fileTypeError.message }, { status: 415 }) + } + + const result = await resolveKnowledgeBase( + knowledgeBaseId, + workspaceId, + userId, + rateLimit, + 'write' ) - } + if (result instanceof NextResponse) return result - const rawFile = formData.get('file') - const file = rawFile instanceof File ? rawFile : null - const rawWorkspaceId = formData.get('workspaceId') - const workspaceId = typeof rawWorkspaceId === 'string' ? rawWorkspaceId : null + const buffer = Buffer.from(await file.arrayBuffer()) + const contentType = file.type || 'application/octet-stream' - if (!workspaceId) { - return NextResponse.json({ error: 'workspaceId form field is required' }, { status: 400 }) - } - - if (!file) { - return NextResponse.json({ error: 'file form field is required' }, { status: 400 }) - } + const uploadedFile = await uploadWorkspaceFile( + workspaceId, + userId, + buffer, + file.name, + contentType + ) - if (file.size > MAX_FILE_SIZE) { - return NextResponse.json( + const newDocument = await createSingleDocument( { - error: `File size exceeds 100MB limit (${(file.size / (1024 * 1024)).toFixed(2)}MB)`, + filename: file.name, + fileUrl: uploadedFile.url, + fileSize: file.size, + mimeType: contentType, }, - { status: 413 } + knowledgeBaseId, + requestId ) - } - const fileTypeError = validateFileType(file.name, file.type || '') - if (fileTypeError) { - return NextResponse.json({ error: fileTypeError.message }, { status: 415 }) - } - - const result = await resolveKnowledgeBase( - knowledgeBaseId, - workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const buffer = Buffer.from(await file.arrayBuffer()) - const contentType = file.type || 'application/octet-stream' - - const uploadedFile = await uploadWorkspaceFile( - workspaceId, - userId, - buffer, - file.name, - contentType - ) - - const newDocument = await createSingleDocument( - { + const documentData: DocumentData = { + documentId: newDocument.id, filename: file.name, fileUrl: uploadedFile.url, fileSize: file.size, mimeType: contentType, - }, - knowledgeBaseId, - requestId - ) - - const documentData: DocumentData = { - documentId: newDocument.id, - filename: file.name, - fileUrl: uploadedFile.url, - fileSize: file.size, - mimeType: contentType, - } - - processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { - // Processing errors are logged internally - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.DOCUMENT_UPLOADED, - resourceType: AuditResourceType.DOCUMENT, - resourceId: newDocument.id, - resourceName: file.name, - description: `Uploaded document "${file.name}" to knowledge base via API`, - request, - }) - - return NextResponse.json({ - success: true, - data: { - document: { - id: newDocument.id, - knowledgeBaseId, - filename: newDocument.filename, - fileSize: newDocument.fileSize, - mimeType: newDocument.mimeType, - processingStatus: 'pending', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: newDocument.enabled, - createdAt: serializeDate(newDocument.uploadedAt), + } + + processDocumentsWithQueue([documentData], knowledgeBaseId, {}, requestId).catch(() => { + // Processing errors are logged internally + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: newDocument.id, + resourceName: file.name, + description: `Uploaded document "${file.name}" to knowledge base via API`, + request, + }) + + return NextResponse.json({ + success: true, + data: { + document: { + id: newDocument.id, + knowledgeBaseId, + filename: newDocument.filename, + fileSize: newDocument.fileSize, + mimeType: newDocument.mimeType, + processingStatus: 'pending', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + enabled: newDocument.enabled, + createdAt: serializeDate(newDocument.uploadedAt), + }, + message: 'Document uploaded successfully. Processing will begin shortly.', }, - message: 'Document uploaded successfully. Processing will begin shortly.', - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to upload document') + }) + } catch (error) { + return handleError(requestId, error, 'Failed to upload document') + } } -} +) diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 0b7012c8770..c7b9989585b 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' import { authenticateRequest, @@ -44,132 +45,138 @@ const UpdateKBSchema = z ) /** GET /api/v1/knowledge/[id] — Get knowledge base details. */ -export async function GET(request: NextRequest, { params }: KnowledgeRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response - - const result = await resolveKnowledgeBase(id, validation.data.workspaceId, userId, rateLimit) - if (result instanceof NextResponse) return result - - return NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(result.kb), - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to get knowledge base') +export const GET = withRouteHandler( + async (request: NextRequest, { params }: KnowledgeRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const { id } = await params + const { searchParams } = new URL(request.url) + + const validation = validateSchema(WorkspaceIdSchema, { + workspaceId: searchParams.get('workspaceId'), + }) + if (!validation.success) return validation.response + + const result = await resolveKnowledgeBase(id, validation.data.workspaceId, userId, rateLimit) + if (result instanceof NextResponse) return result + + return NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(result.kb), + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to get knowledge base') + } } -} +) /** PUT /api/v1/knowledge/[id] — Update a knowledge base. */ -export async function PUT(request: NextRequest, { params }: KnowledgeRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id } = await params - - const body = await parseJsonBody(request) - if (!body.success) return body.response - - const validation = validateSchema(UpdateKBSchema, body.data) - if (!validation.success) return validation.response - - const { workspaceId, name, description, chunkingConfig } = validation.data - - const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') - if (result instanceof NextResponse) return result - - const updates: { - name?: string - description?: string - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - } = {} - if (name !== undefined) updates.name = name - if (description !== undefined) updates.description = description - if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig - - const updatedKb = await updateKnowledgeBase(id, updates, requestId) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_UPDATED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: updatedKb.name, - description: `Updated knowledge base "${updatedKb.name}" via API`, - request, - }) - - return NextResponse.json({ - success: true, - data: { - knowledgeBase: formatKnowledgeBase(updatedKb), - message: 'Knowledge base updated successfully', - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to update knowledge base') +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: KnowledgeRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const { id } = await params + + const body = await parseJsonBody(request) + if (!body.success) return body.response + + const validation = validateSchema(UpdateKBSchema, body.data) + if (!validation.success) return validation.response + + const { workspaceId, name, description, chunkingConfig } = validation.data + + const result = await resolveKnowledgeBase(id, workspaceId, userId, rateLimit, 'write') + if (result instanceof NextResponse) return result + + const updates: { + name?: string + description?: string + chunkingConfig?: { maxSize: number; minSize: number; overlap: number } + } = {} + if (name !== undefined) updates.name = name + if (description !== undefined) updates.description = description + if (chunkingConfig !== undefined) updates.chunkingConfig = chunkingConfig + + const updatedKb = await updateKnowledgeBase(id, updates, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: updatedKb.name, + description: `Updated knowledge base "${updatedKb.name}" via API`, + request, + }) + + return NextResponse.json({ + success: true, + data: { + knowledgeBase: formatKnowledgeBase(updatedKb), + message: 'Knowledge base updated successfully', + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to update knowledge base') + } } -} +) /** DELETE /api/v1/knowledge/[id] — Delete a knowledge base. */ -export async function DELETE(request: NextRequest, { params }: KnowledgeRouteParams) { - const auth = await authenticateRequest(request, 'knowledge-detail') - if (auth instanceof NextResponse) return auth - const { requestId, userId, rateLimit } = auth - - try { - const { id } = await params - const { searchParams } = new URL(request.url) - - const validation = validateSchema(WorkspaceIdSchema, { - workspaceId: searchParams.get('workspaceId'), - }) - if (!validation.success) return validation.response - - const result = await resolveKnowledgeBase( - id, - validation.data.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - await deleteKnowledgeBase(id, requestId) - - recordAudit({ - workspaceId: validation.data.workspaceId, - actorId: userId, - action: AuditAction.KNOWLEDGE_BASE_DELETED, - resourceType: AuditResourceType.KNOWLEDGE_BASE, - resourceId: id, - resourceName: result.kb.name, - description: `Deleted knowledge base "${result.kb.name}" via API`, - request, - }) - - return NextResponse.json({ - success: true, - data: { - message: 'Knowledge base deleted successfully', - }, - }) - } catch (error) { - return handleError(requestId, error, 'Failed to delete knowledge base') +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: KnowledgeRouteParams) => { + const auth = await authenticateRequest(request, 'knowledge-detail') + if (auth instanceof NextResponse) return auth + const { requestId, userId, rateLimit } = auth + + try { + const { id } = await params + const { searchParams } = new URL(request.url) + + const validation = validateSchema(WorkspaceIdSchema, { + workspaceId: searchParams.get('workspaceId'), + }) + if (!validation.success) return validation.response + + const result = await resolveKnowledgeBase( + id, + validation.data.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + await deleteKnowledgeBase(id, requestId) + + recordAudit({ + workspaceId: validation.data.workspaceId, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: result.kb.name, + description: `Deleted knowledge base "${result.kb.name}" via API`, + request, + }) + + return NextResponse.json({ + success: true, + data: { + message: 'Knowledge base deleted successfully', + }, + }) + } catch (error) { + return handleError(requestId, error, 'Failed to delete knowledge base') + } } -} +) diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 9d45e677bd3..28a22b8385b 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { authenticateRequest, @@ -36,7 +37,7 @@ const CreateKBSchema = z.object({ }) /** GET /api/v1/knowledge — List knowledge bases in a workspace. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const auth = await authenticateRequest(request, 'knowledge') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth @@ -65,10 +66,10 @@ export async function GET(request: NextRequest) { } catch (error) { return handleError(requestId, error, 'Failed to list knowledge bases') } -} +}) /** POST /api/v1/knowledge — Create a new knowledge base. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await authenticateRequest(request, 'knowledge') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth @@ -119,4 +120,4 @@ export async function POST(request: NextRequest) { } catch (error) { return handleError(requestId, error, 'Failed to create knowledge base') } -} +}) diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 1b50d5d8af4..4a622ff0bcf 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' @@ -59,7 +60,7 @@ const SearchSchema = z ) /** POST /api/v1/knowledge/search — Vector search across knowledge bases. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const auth = await authenticateRequest(request, 'knowledge-search') if (auth instanceof NextResponse) return auth const { requestId, userId, rateLimit } = auth @@ -265,4 +266,4 @@ export async function POST(request: NextRequest) { } catch (error) { return handleError(requestId, error, 'Failed to perform search') } -} +}) diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index e9b33de99ff..6373f4a27d5 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -11,98 +12,100 @@ const logger = createLogger('V1LogDetailsAPI') export const revalidate = 0 -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) - try { - const rateLimit = await checkRateLimit(request, 'logs-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const userId = rateLimit.userId! - const { id } = await params + const userId = rateLimit.userId! + const { id } = await params - const rows = await db - .select({ - id: workflowExecutionLogs.id, - workflowId: workflowExecutionLogs.workflowId, - executionId: workflowExecutionLogs.executionId, - stateSnapshotId: workflowExecutionLogs.stateSnapshotId, - level: workflowExecutionLogs.level, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - executionData: workflowExecutionLogs.executionData, - cost: workflowExecutionLogs.cost, - files: workflowExecutionLogs.files, - createdAt: workflowExecutionLogs.createdAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - workflowColor: workflow.color, - workflowFolderId: workflow.folderId, - workflowUserId: workflow.userId, - workflowWorkspaceId: workflow.workspaceId, - workflowCreatedAt: workflow.createdAt, - workflowUpdatedAt: workflow.updatedAt, - }) - .from(workflowExecutionLogs) - .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) + const rows = await db + .select({ + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + stateSnapshotId: workflowExecutionLogs.stateSnapshotId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + executionData: workflowExecutionLogs.executionData, + cost: workflowExecutionLogs.cost, + files: workflowExecutionLogs.files, + createdAt: workflowExecutionLogs.createdAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + workflowColor: workflow.color, + workflowFolderId: workflow.folderId, + workflowUserId: workflow.userId, + workflowWorkspaceId: workflow.workspaceId, + workflowCreatedAt: workflow.createdAt, + workflowUpdatedAt: workflow.updatedAt, + }) + .from(workflowExecutionLogs) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) ) - ) - .where(eq(workflowExecutionLogs.id, id)) - .limit(1) + .where(eq(workflowExecutionLogs.id, id)) + .limit(1) - const log = rows[0] - if (!log) { - return NextResponse.json({ error: 'Log not found' }, { status: 404 }) - } + const log = rows[0] + if (!log) { + return NextResponse.json({ error: 'Log not found' }, { status: 404 }) + } - const workflowSummary = { - id: log.workflowId, - name: log.workflowName || 'Deleted Workflow', - description: log.workflowDescription, - color: log.workflowColor, - folderId: log.workflowFolderId, - userId: log.workflowUserId, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt, - updatedAt: log.workflowUpdatedAt, - deleted: !log.workflowName, - } + const workflowSummary = { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + color: log.workflowColor, + folderId: log.workflowFolderId, + userId: log.workflowUserId, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt, + updatedAt: log.workflowUpdatedAt, + deleted: !log.workflowName, + } - const response = { - id: log.id, - workflowId: log.workflowId, - executionId: log.executionId, - level: log.level, - trigger: log.trigger, - startedAt: log.startedAt.toISOString(), - endedAt: log.endedAt?.toISOString() || null, - totalDurationMs: log.totalDurationMs, - files: log.files || undefined, - workflow: workflowSummary, - executionData: log.executionData as any, - cost: log.cost as any, - createdAt: log.createdAt.toISOString(), - } + const response = { + id: log.id, + workflowId: log.workflowId, + executionId: log.executionId, + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt?.toISOString() || null, + totalDurationMs: log.totalDurationMs, + files: log.files || undefined, + workflow: workflowSummary, + executionData: log.executionData as any, + cost: log.cost as any, + createdAt: log.createdAt.toISOString(), + } - // Get user's workflow execution limits and usage - const limits = await getUserLimits(userId) + // Get user's workflow execution limits and usage + const limits = await getUserLimits(userId) - // Create response with limits information - const apiResponse = createApiResponse({ data: response }, limits, rateLimit) + // Create response with limits information + const apiResponse = createApiResponse({ data: response }, limits, rateLimit) - return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) - } catch (error: any) { - logger.error(`[${requestId}] Log details fetch error`, { error: error.message }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: any) { + logger.error(`[${requestId}] Log details fetch error`, { error: error.message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts index f791c13b25f..54af2f3ea83 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -3,91 +3,91 @@ import { permissions, workflowExecutionLogs, workflowExecutionSnapshots } from ' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' const logger = createLogger('V1ExecutionAPI') -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ executionId: string }> } -) { - try { - const rateLimit = await checkRateLimit(request, 'logs-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ executionId: string }> }) => { + try { + const rateLimit = await checkRateLimit(request, 'logs-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const userId = rateLimit.userId! - const { executionId } = await params + const userId = rateLimit.userId! + const { executionId } = await params - logger.debug(`Fetching execution data for: ${executionId}`) + logger.debug(`Fetching execution data for: ${executionId}`) - const rows = await db - .select({ - log: workflowExecutionLogs, - }) - .from(workflowExecutionLogs) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflowExecutionLogs.workspaceId), - eq(permissions.userId, userId) + const rows = await db + .select({ + log: workflowExecutionLogs, + }) + .from(workflowExecutionLogs) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), + eq(permissions.userId, userId) + ) ) - ) - .where(eq(workflowExecutionLogs.executionId, executionId)) - .limit(1) + .where(eq(workflowExecutionLogs.executionId, executionId)) + .limit(1) - if (rows.length === 0) { - return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) - } + if (rows.length === 0) { + return NextResponse.json({ error: 'Workflow execution not found' }, { status: 404 }) + } - const { log: workflowLog } = rows[0] + const { log: workflowLog } = rows[0] - const [snapshot] = await db - .select() - .from(workflowExecutionSnapshots) - .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) - .limit(1) + const [snapshot] = await db + .select() + .from(workflowExecutionSnapshots) + .where(eq(workflowExecutionSnapshots.id, workflowLog.stateSnapshotId)) + .limit(1) - if (!snapshot) { - return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) - } + if (!snapshot) { + return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 }) + } - const response = { - executionId, - workflowId: workflowLog.workflowId, - workflowState: snapshot.stateData, - executionMetadata: { - trigger: workflowLog.trigger, - startedAt: workflowLog.startedAt.toISOString(), - endedAt: workflowLog.endedAt?.toISOString(), - totalDurationMs: workflowLog.totalDurationMs, - cost: workflowLog.cost || null, - }, - } + const response = { + executionId, + workflowId: workflowLog.workflowId, + workflowState: snapshot.stateData, + executionMetadata: { + trigger: workflowLog.trigger, + startedAt: workflowLog.startedAt.toISOString(), + endedAt: workflowLog.endedAt?.toISOString(), + totalDurationMs: workflowLog.totalDurationMs, + cost: workflowLog.cost || null, + }, + } - logger.debug(`Successfully fetched execution data for: ${executionId}`) - logger.debug( - `Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` - ) + logger.debug(`Successfully fetched execution data for: ${executionId}`) + logger.debug( + `Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks` + ) - // Get user's workflow execution limits and usage - const limits = await getUserLimits(userId) + // Get user's workflow execution limits and usage + const limits = await getUserLimits(userId) - // Create response with limits information - const apiResponse = createApiResponse( - { - ...response, - }, - limits, - rateLimit - ) + // Create response with limits information + const apiResponse = createApiResponse( + { + ...response, + }, + limits, + rateLimit + ) - return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) - } catch (error) { - logger.error('Error fetching execution data:', error) - return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 }) + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error) { + logger.error('Error fetching execution data:', error) + return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index cab370d8141..bd73a1474d1 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -5,6 +5,7 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -53,7 +54,7 @@ function decodeCursor(cursor: string): CursorData | null { } } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -208,4 +209,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Logs fetch error`, { error: error.message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts index fb707274bfc..09ef58e5f3e 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/columns/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { addTableColumn, deleteColumn, @@ -34,272 +35,278 @@ interface ColumnsRouteParams { } /** POST /api/v1/tables/[tableId]/columns — Add a column to the table schema. */ -export async function POST(request: NextRequest, { params }: ColumnsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! +export const POST = withRouteHandler( + async (request: NextRequest, { params }: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const validated = CreateColumnSchema.parse(body) + const userId = rateLimit.userId! - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const validated = CreateColumnSchema.parse(body) - const { table } = result + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const updatedTable = await addTableColumn(tableId, validated.column, requestId) - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Added column "${validated.column.name}" to table "${table.name}"`, - metadata: { column: validated.column }, - request, - }) - - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const { table } = result - if (error instanceof Error) { - if (error.message.includes('already exists') || error.message.includes('maximum column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - if (error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) + + const updatedTable = await addTableColumn(tableId, validated.column, requestId) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) + + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) } - } - logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) - } -} + if (error instanceof Error) { + if (error.message.includes('already exists') || error.message.includes('maximum column')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (error.message === 'Table not found') { + return NextResponse.json({ error: error.message }, { status: 404 }) + } + } -/** PATCH /api/v1/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ -export async function PATCH(request: NextRequest, { params }: ColumnsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) + logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to add column' }, { status: 500 }) } + } +) - const userId = rateLimit.userId! +/** PATCH /api/v1/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const validated = UpdateColumnSchema.parse(body) + const userId = rateLimit.userId! - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const validated = UpdateColumnSchema.parse(body) - const { table } = result + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - const { updates } = validated - let updatedTable = null + const { table } = result - if (updates.name) { - updatedTable = await renameColumn( - { tableId, oldName: validated.columnName, newName: updates.name }, - requestId - ) - } + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - if (updates.type) { - updatedTable = await updateColumnType( - { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, - requestId - ) - } + const { updates } = validated + let updatedTable = null - if (updates.required !== undefined || updates.unique !== undefined) { - updatedTable = await updateColumnConstraints( - { - tableId, - columnName: updates.name ?? validated.columnName, - ...(updates.required !== undefined ? { required: updates.required } : {}), - ...(updates.unique !== undefined ? { unique: updates.unique } : {}), - }, - requestId - ) - } + if (updates.name) { + updatedTable = await renameColumn( + { tableId, oldName: validated.columnName, newName: updates.name }, + requestId + ) + } - if (!updatedTable) { - return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) - } + if (updates.type) { + updatedTable = await updateColumnType( + { tableId, columnName: updates.name ?? validated.columnName, newType: updates.type }, + requestId + ) + } - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${validated.columnName}" in table "${table.name}"`, - metadata: { columnName: validated.columnName, updates }, - request, - }) - - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + if (updates.required !== undefined || updates.unique !== undefined) { + updatedTable = await updateColumnConstraints( + { + tableId, + columnName: updates.name ?? validated.columnName, + ...(updates.required !== undefined ? { required: updates.required } : {}), + ...(updates.unique !== undefined ? { unique: updates.unique } : {}), + }, + requestId + ) + } - if (error instanceof Error) { - const msg = error.message - if (msg.includes('not found') || msg.includes('Table not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) + if (!updatedTable) { + return NextResponse.json({ error: 'No updates specified' }, { status: 400 }) } - if ( - msg.includes('already exists') || - msg.includes('Cannot delete the last column') || - msg.includes('Cannot set column') || - msg.includes('Invalid column') || - msg.includes('exceeds maximum') || - msg.includes('incompatible') || - msg.includes('duplicate') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) + + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${validated.columnName}" in table "${table.name}"`, + metadata: { columnName: validated.columnName, updates }, + request, + }) + + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) } - } - logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) - } -} + if (error instanceof Error) { + const msg = error.message + if (msg.includes('not found') || msg.includes('Table not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + if ( + msg.includes('already exists') || + msg.includes('Cannot delete the last column') || + msg.includes('Cannot set column') || + msg.includes('Invalid column') || + msg.includes('exceeds maximum') || + msg.includes('incompatible') || + msg.includes('duplicate') + ) { + return NextResponse.json({ error: msg }, { status: 400 }) + } + } -/** DELETE /api/v1/tables/[tableId]/columns — Delete a column from the table schema. */ -export async function DELETE(request: NextRequest, { params }: ColumnsRouteParams) { - const requestId = generateRequestId() - const { tableId } = await params - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) + logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to update column' }, { status: 500 }) } + } +) - const userId = rateLimit.userId! +/** DELETE /api/v1/tables/[tableId]/columns — Delete a column from the table schema. */ +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: ColumnsRouteParams) => { + const requestId = generateRequestId() + const { tableId } = await params - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const rateLimit = await checkRateLimit(request, 'table-columns') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! - const validated = DeleteColumnSchema.parse(body) + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const validated = DeleteColumnSchema.parse(body) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const { table } = result + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const { table } = result + + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const updatedTable = await deleteColumn( - { tableId, columnName: validated.columnName }, - requestId - ) - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Deleted column "${validated.columnName}" from table "${table.name}"`, - metadata: { columnName: validated.columnName }, - request, - }) - - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + const updatedTable = await deleteColumn( + { tableId, columnName: validated.columnName }, + requestId ) - } - if (error instanceof Error) { - if (error.message.includes('not found') || error.message === 'Table not found') { - return NextResponse.json({ error: error.message }, { status: 404 }) + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Deleted column "${validated.columnName}" from table "${table.name}"`, + metadata: { columnName: validated.columnName }, + request, + }) + + return NextResponse.json({ + success: true, + data: { + columns: updatedTable.schema.columns.map(normalizeColumn), + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) } - if (error.message.includes('Cannot delete') || error.message.includes('last column')) { - return NextResponse.json({ error: error.message }, { status: 400 }) + + if (error instanceof Error) { + if (error.message.includes('not found') || error.message === 'Table not found') { + return NextResponse.json({ error: error.message }, { status: 404 }) + } + if (error.message.includes('Cannot delete') || error.message.includes('last column')) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } } - } - logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) - return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) + logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error) + return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index 06c2a1de4fb..67bfd5dd8fc 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteTable, type TableSchema } from '@/lib/table' import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils' import { @@ -20,7 +21,7 @@ interface TableRouteParams { } /** GET /api/v1/tables/[tableId] — Get table details. */ -export async function GET(request: NextRequest, { params }: TableRouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: TableRouteParams) => { const requestId = generateRequestId() try { @@ -82,61 +83,63 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) { logger.error(`[${requestId}] Error getting table:`, error) return NextResponse.json({ error: 'Failed to get table' }, { status: 500 }) } -} +}) /** DELETE /api/v1/tables/[tableId] — Archive a table. */ -export async function DELETE(request: NextRequest, { params }: TableRouteParams) { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params - const { searchParams } = new URL(request.url) - const workspaceId = searchParams.get('workspaceId') - - if (!workspaceId) { - return NextResponse.json( - { error: 'workspaceId query parameter is required' }, - { status: 400 } - ) - } - - const scopeError = checkWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return scopeError - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - - if (result.table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: TableRouteParams) => { + const requestId = generateRequestId() + + try { + const rateLimit = await checkRateLimit(request, 'table-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { tableId } = await params + const { searchParams } = new URL(request.url) + const workspaceId = searchParams.get('workspaceId') + + if (!workspaceId) { + return NextResponse.json( + { error: 'workspaceId query parameter is required' }, + { status: 400 } + ) + } + + const scopeError = checkWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return scopeError + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + if (result.table.workspaceId !== workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + await deleteTable(tableId, requestId) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: result.table.name, + description: `Archived table "${result.table.name}"`, + request, + }) + + return NextResponse.json({ + success: true, + data: { + message: 'Table archived successfully', + }, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting table:`, error) + return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } - - await deleteTable(tableId, requestId) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: result.table.name, - description: `Archived table "${result.table.name}"`, - request, - }) - - return NextResponse.json({ - success: true, - data: { - message: 'Table archived successfully', - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting table:`, error) - return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index bc7901a80d0..2ff8f5fe396 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' import { updateRow } from '@/lib/table' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -29,7 +30,7 @@ interface RowRouteParams { } /** GET /api/v1/tables/[tableId]/rows/[rowId] — Get a single row. */ -export async function GET(request: NextRequest, { params }: RowRouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { const requestId = generateRequestId() try { @@ -100,10 +101,10 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) { logger.error(`[${requestId}] Error getting row:`, error) return NextResponse.json({ error: 'Failed to get row' }, { status: 500 }) } -} +}) /** PATCH /api/v1/tables/[tableId]/rows/[rowId] — Partial update a single row. */ -export async function PATCH(request: NextRequest, { params }: RowRouteParams) { +export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { const requestId = generateRequestId() try { @@ -215,10 +216,10 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) { logger.error(`[${requestId}] Error updating row:`, error) return NextResponse.json({ error: 'Failed to update row' }, { status: 500 }) } -} +}) /** DELETE /api/v1/tables/[tableId]/rows/[rowId] — Delete a single row. */ -export async function DELETE(request: NextRequest, { params }: RowRouteParams) { +export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RowRouteParams) => { const requestId = generateRequestId() try { @@ -275,4 +276,4 @@ export async function DELETE(request: NextRequest, { params }: RowRouteParams) { logger.error(`[${requestId}] Error deleting row:`, error) return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts index 8021625b1b8..bf8fc067ad1 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -5,6 +5,7 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { Filter, RowData, Sort, TableSchema } from '@/lib/table' import { batchInsertRows, @@ -181,369 +182,403 @@ async function handleBatchInsert( } /** GET /api/v1/tables/[tableId]/rows — Query rows with filtering, sorting, pagination. */ -export async function GET(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params - const { searchParams } = new URL(request.url) - - let filter: Record | undefined - let sort: Sort | undefined +export const GET = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() try { - const filterParam = searchParams.get('filter') - const sortParam = searchParams.get('sort') - if (filterParam) { - filter = JSON.parse(filterParam) as Record + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) } - if (sortParam) { - sort = JSON.parse(sortParam) as Sort + + const userId = rateLimit.userId! + const { tableId } = await params + const { searchParams } = new URL(request.url) + + let filter: Record | undefined + let sort: Sort | undefined + + try { + const filterParam = searchParams.get('filter') + const sortParam = searchParams.get('sort') + if (filterParam) { + filter = JSON.parse(filterParam) as Record + } + if (sortParam) { + sort = JSON.parse(sortParam) as Sort + } + } catch { + return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) } - } catch { - return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 }) - } - const validated = QueryRowsSchema.parse({ - workspaceId: searchParams.get('workspaceId'), - filter, - sort, - limit: searchParams.get('limit'), - offset: searchParams.get('offset'), - }) + const validated = QueryRowsSchema.parse({ + workspaceId: searchParams.get('workspaceId'), + filter, + sort, + limit: searchParams.get('limit'), + offset: searchParams.get('offset'), + }) - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'read') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const accessResult = await checkAccess(tableId, userId, 'read') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (validated.workspaceId !== table.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const baseConditions = [ - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId), - ] + const baseConditions = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, validated.workspaceId), + ] - if (validated.filter) { - const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) - if (filterClause) { - baseConditions.push(filterClause) + if (validated.filter) { + const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME) + if (filterClause) { + baseConditions.push(filterClause) + } } - } - let query = db - .select({ - id: userTableRows.id, - data: userTableRows.data, - position: userTableRows.position, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where(and(...baseConditions)) - - if (validated.sort) { - const schema = table.schema as TableSchema - const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) - if (sortClause) { - query = query.orderBy(sortClause) as typeof query + let query = db + .select({ + id: userTableRows.id, + data: userTableRows.data, + position: userTableRows.position, + createdAt: userTableRows.createdAt, + updatedAt: userTableRows.updatedAt, + }) + .from(userTableRows) + .where(and(...baseConditions)) + + if (validated.sort) { + const schema = table.schema as TableSchema + const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns) + if (sortClause) { + query = query.orderBy(sortClause) as typeof query + } else { + query = query.orderBy(userTableRows.position) as typeof query + } } else { query = query.orderBy(userTableRows.position) as typeof query } - } else { - query = query.orderBy(userTableRows.position) as typeof query - } - const countQuery = db - .select({ count: sql`count(*)` }) - .from(userTableRows) - .where(and(...baseConditions)) + const countQuery = db + .select({ count: sql`count(*)` }) + .from(userTableRows) + .where(and(...baseConditions)) - const [countResult, rows] = await Promise.all([ - countQuery, - query.limit(validated.limit).offset(validated.offset), - ]) - const totalCount = countResult[0].count + const [countResult, rows] = await Promise.all([ + countQuery, + query.limit(validated.limit).offset(validated.offset), + ]) + const totalCount = countResult[0].count - return NextResponse.json({ - success: true, - data: { - rows: rows.map((r) => ({ - id: r.id, - data: r.data, - position: r.position, - createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), - updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), - })), - rowCount: rows.length, - totalCount: Number(totalCount), - limit: validated.limit, - offset: validated.offset, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + return NextResponse.json({ + success: true, + data: { + rows: rows.map((r) => ({ + id: r.id, + data: r.data, + position: r.position, + createdAt: + r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt), + updatedAt: + r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt), + })), + rowCount: rows.length, + totalCount: Number(totalCount), + limit: validated.limit, + offset: validated.offset, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - logger.error(`[${requestId}] Error querying rows:`, error) - return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + logger.error(`[${requestId}] Error querying rows:`, error) + return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 }) + } } -} +) /** POST /api/v1/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ -export async function POST(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params +export const POST = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - if ( - typeof body === 'object' && - body !== null && - 'rows' in body && - Array.isArray((body as Record).rows) - ) { - const batchValidated = BatchInsertRowsSchema.parse(body) - const scopeError = checkWorkspaceScope(rateLimit, batchValidated.workspaceId) - if (scopeError) return scopeError - return handleBatchInsert(requestId, tableId, batchValidated, userId) - } + const userId = rateLimit.userId! + const { tableId } = await params - const validated = InsertRowSchema.parse(body) + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + if ( + typeof body === 'object' && + body !== null && + 'rows' in body && + Array.isArray((body as Record).rows) + ) { + const batchValidated = BatchInsertRowsSchema.parse(body) + const scopeError = checkWorkspaceScope(rateLimit, batchValidated.workspaceId) + if (scopeError) return scopeError + return handleBatchInsert(requestId, tableId, batchValidated, userId) + } - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const validated = InsertRowSchema.parse(body) - const { table } = accessResult + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - if (validated.workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const rowData = validated.data as RowData + const { table } = accessResult - const validation = await validateRowData({ - rowData, - schema: table.schema as TableSchema, - tableId, - }) - if (!validation.valid) return validation.response + if (validated.workspaceId !== table.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - const row = await insertRow( - { - tableId, - data: rowData, - workspaceId: validated.workspaceId, - userId, - }, - table, - requestId - ) + const rowData = validated.data as RowData - return NextResponse.json({ - success: true, - data: { - row: { - id: row.id, - data: row.data, - position: row.position, - createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, - updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, + const validation = await validateRowData({ + rowData, + schema: table.schema as TableSchema, + tableId, + }) + if (!validation.valid) return validation.response + + const row = await insertRow( + { + tableId, + data: rowData, + workspaceId: validated.workspaceId, + userId, }, - message: 'Row inserted successfully', - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + table, + requestId ) - } - const errorMessage = error instanceof Error ? error.message : String(error) + return NextResponse.json({ + success: true, + data: { + row: { + id: row.id, + data: row.data, + position: row.position, + createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt, + updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, + }, + message: 'Row inserted successfully', + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - if ( - errorMessage.includes('row limit') || - errorMessage.includes('Insufficient capacity') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) - } + const errorMessage = error instanceof Error ? error.message : String(error) - logger.error(`[${requestId}] Error inserting row:`, error) - return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) + if ( + errorMessage.includes('row limit') || + errorMessage.includes('Insufficient capacity') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Row size exceeds') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } + + logger.error(`[${requestId}] Error inserting row:`, error) + return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 }) + } } -} +) /** PUT /api/v1/tables/[tableId]/rows — Bulk update rows by filter. */ -export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const userId = rateLimit.userId! - const { tableId } = await params + const userId = rateLimit.userId! + const { tableId } = await params - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const validated = UpdateRowsByFilterSchema.parse(body) + const validated = UpdateRowsByFilterSchema.parse(body) - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (validated.workspaceId !== table.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const sizeValidation = validateRowSize(validated.data as RowData) + if (!sizeValidation.valid) { + return NextResponse.json( + { error: 'Validation error', details: sizeValidation.errors }, + { status: 400 } + ) + } - const sizeValidation = validateRowSize(validated.data as RowData) - if (!sizeValidation.valid) { - return NextResponse.json( - { error: 'Validation error', details: sizeValidation.errors }, - { status: 400 } + const result = await updateRowsByFilter( + { + tableId, + filter: validated.filter as Filter, + data: validated.data as RowData, + limit: validated.limit, + workspaceId: validated.workspaceId, + }, + table, + requestId ) - } - const result = await updateRowsByFilter( - { - tableId, - filter: validated.filter as Filter, - data: validated.data as RowData, - limit: validated.limit, - workspaceId: validated.workspaceId, - }, - table, - requestId - ) + if (result.affectedCount === 0) { + return NextResponse.json({ + success: true, + data: { + message: 'No rows matched the filter criteria', + updatedCount: 0, + }, + }) + } - if (result.affectedCount === 0) { return NextResponse.json({ success: true, data: { - message: 'No rows matched the filter criteria', - updatedCount: 0, + message: 'Rows updated successfully', + updatedCount: result.affectedCount, + updatedRowIds: result.affectedRowIds, }, }) - } - - return NextResponse.json({ - success: true, - data: { - message: 'Rows updated successfully', - updatedCount: result.affectedCount, - updatedRowIds: result.affectedRowIds, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - const errorMessage = error instanceof Error ? error.message : String(error) + const errorMessage = error instanceof Error ? error.message : String(error) + + if ( + errorMessage.includes('Row size exceeds') || + errorMessage.includes('Schema validation') || + errorMessage.includes('must be unique') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('Cannot set unique column') || + errorMessage.includes('Filter is required') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - if ( - errorMessage.includes('Row size exceeds') || - errorMessage.includes('Schema validation') || - errorMessage.includes('must be unique') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('Cannot set unique column') || - errorMessage.includes('Filter is required') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) + logger.error(`[${requestId}] Error updating rows by filter:`, error) + return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) } - - logger.error(`[${requestId}] Error updating rows by filter:`, error) - return NextResponse.json({ error: 'Failed to update rows' }, { status: 500 }) } -} +) /** DELETE /api/v1/tables/[tableId]/rows — Delete rows by filter or IDs. */ -export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) { - const requestId = generateRequestId() +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: TableRowsRouteParams) => { + const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } + try { + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const userId = rateLimit.userId! - const { tableId } = await params + const userId = rateLimit.userId! + const { tableId } = await params - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } - const validated = DeleteRowsRequestSchema.parse(body) + const validated = DeleteRowsRequestSchema.parse(body) - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return accessError(accessResult, requestId, tableId) + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return accessError(accessResult, requestId, tableId) - const { table } = accessResult + const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } + if (validated.workspaceId !== table.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } - if ('rowIds' in validated) { - const result = await deleteRowsByIds( - { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + if ('rowIds' in validated) { + const result = await deleteRowsByIds( + { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, + requestId + ) + + return NextResponse.json({ + success: true, + data: { + message: + result.deletedCount === 0 + ? 'No matching rows found for the provided IDs' + : 'Rows deleted successfully', + deletedCount: result.deletedCount, + deletedRowIds: result.deletedRowIds, + requestedCount: result.requestedCount, + ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), + }, + }) + } + + const result = await deleteRowsByFilter( + { + tableId, + filter: validated.filter as Filter, + limit: validated.limit, + workspaceId: validated.workspaceId, + }, requestId ) @@ -551,53 +586,29 @@ export async function DELETE(request: NextRequest, { params }: TableRowsRoutePar success: true, data: { message: - result.deletedCount === 0 - ? 'No matching rows found for the provided IDs' + result.affectedCount === 0 + ? 'No rows matched the filter criteria' : 'Rows deleted successfully', - deletedCount: result.deletedCount, - deletedRowIds: result.deletedRowIds, - requestedCount: result.requestedCount, - ...(result.missingRowIds.length > 0 ? { missingRowIds: result.missingRowIds } : {}), + deletedCount: result.affectedCount, + deletedRowIds: result.affectedRowIds, }, }) - } - - const result = await deleteRowsByFilter( - { - tableId, - filter: validated.filter as Filter, - limit: validated.limit, - workspaceId: validated.workspaceId, - }, - requestId - ) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } - return NextResponse.json({ - success: true, - data: { - message: - result.affectedCount === 0 - ? 'No rows matched the filter criteria' - : 'Rows deleted successfully', - deletedCount: result.affectedCount, - deletedRowIds: result.affectedRowIds, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } - ) - } + const errorMessage = error instanceof Error ? error.message : String(error) - const errorMessage = error instanceof Error ? error.message : String(error) + if (errorMessage.includes('Filter is required')) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } - if (errorMessage.includes('Filter is required')) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) + logger.error(`[${requestId}] Error deleting rows:`, error) + return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) } - - logger.error(`[${requestId}] Error deleting rows:`, error) - return NextResponse.json({ error: 'Failed to delete rows' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts index 93f1351a8f2..4b3bb6657ba 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/upsert/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { RowData } from '@/lib/table' import { upsertRow } from '@/lib/table' import { accessError, checkAccess } from '@/app/api/table/utils' @@ -27,93 +28,95 @@ interface UpsertRouteParams { } /** POST /api/v1/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ -export async function POST(request: NextRequest, { params }: UpsertRouteParams) { - const requestId = generateRequestId() +export const POST = withRouteHandler( + async (request: NextRequest, { params }: UpsertRouteParams) => { + const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } - - const userId = rateLimit.userId! - const { tableId } = await params - - let body: unknown try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) - } - - const validated = UpsertRowSchema.parse(body) - - const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return scopeError - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - - const { table } = result - - if (table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: validated.data as RowData, - userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) - - return NextResponse.json({ - success: true, - data: { - row: { - id: upsertResult.row.id, - data: upsertResult.row.data, - createdAt: - upsertResult.row.createdAt instanceof Date - ? upsertResult.row.createdAt.toISOString() - : upsertResult.row.createdAt, - updatedAt: - upsertResult.row.updatedAt instanceof Date - ? upsertResult.row.updatedAt.toISOString() - : upsertResult.row.updatedAt, + const rateLimit = await checkRateLimit(request, 'table-rows') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { tableId } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 }) + } + + const validated = UpsertRowSchema.parse(body) + + const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return scopeError + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return accessError(result, requestId, tableId) + + const { table } = result + + if (table.workspaceId !== validated.workspaceId) { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: validated.data as RowData, + userId, + conflictTarget: validated.conflictTarget, }, - operation: upsertResult.operation, - message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Validation error', details: error.errors }, - { status: 400 } + table, + requestId ) - } - const errorMessage = error instanceof Error ? error.message : String(error) - - if ( - errorMessage.includes('unique column') || - errorMessage.includes('Unique constraint violation') || - errorMessage.includes('conflictTarget') || - errorMessage.includes('row limit') || - errorMessage.includes('Schema validation') || - errorMessage.includes('Upsert requires') || - errorMessage.includes('Row size exceeds') - ) { - return NextResponse.json({ error: errorMessage }, { status: 400 }) + return NextResponse.json({ + success: true, + data: { + row: { + id: upsertResult.row.id, + data: upsertResult.row.data, + createdAt: + upsertResult.row.createdAt instanceof Date + ? upsertResult.row.createdAt.toISOString() + : upsertResult.row.createdAt, + updatedAt: + upsertResult.row.updatedAt instanceof Date + ? upsertResult.row.updatedAt.toISOString() + : upsertResult.row.updatedAt, + }, + operation: upsertResult.operation, + message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : String(error) + + if ( + errorMessage.includes('unique column') || + errorMessage.includes('Unique constraint violation') || + errorMessage.includes('conflictTarget') || + errorMessage.includes('row limit') || + errorMessage.includes('Schema validation') || + errorMessage.includes('Upsert requires') || + errorMessage.includes('Row size exceeds') + ) { + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } + + logger.error(`[${requestId}] Error upserting row:`, error) + return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) } - - logger.error(`[${requestId}] Error upserting row:`, error) - return NextResponse.json({ error: 'Failed to upsert row' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 09ff717f9cd..1ff9796f305 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTable, getWorkspaceTableLimits, @@ -80,7 +81,7 @@ const CreateTableSchema = z.object({ }) /** GET /api/v1/tables — List all tables in a workspace. */ -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -148,10 +149,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error listing tables:`, error) return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 }) } -} +}) /** POST /api/v1/tables — Create a new table. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -257,4 +258,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error creating table:`, error) return NextResponse.json({ error: 'Failed to create table' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 15a97f9b9ad..0c335cfc632 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -14,73 +15,75 @@ const logger = createLogger('V1WorkflowDetailsAPI') export const revalidate = 0 -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateId().slice(0, 8) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateId().slice(0, 8) - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) { - return createRateLimitResponse(rateLimit) - } + try { + const rateLimit = await checkRateLimit(request, 'workflow-detail') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } - const userId = rateLimit.userId! - const { id } = await params + const userId = rateLimit.userId! + const { id } = await params - logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId }) + logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId }) - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } - const permission = await getUserEntityPermissions( - userId, - 'workspace', - workflowData.workspaceId! - ) - if (!permission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const permission = await getUserEntityPermissions( + userId, + 'workspace', + workflowData.workspaceId! + ) + if (!permission) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - const blockRows = await db - .select({ - id: workflowBlocks.id, - type: workflowBlocks.type, - subBlocks: workflowBlocks.subBlocks, - }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, id)) + const blockRows = await db + .select({ + id: workflowBlocks.id, + type: workflowBlocks.type, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .where(eq(workflowBlocks.workflowId, id)) - const blocksRecord = Object.fromEntries( - blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) - ) - const inputs = extractInputFieldsFromBlocks(blocksRecord) + const blocksRecord = Object.fromEntries( + blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }]) + ) + const inputs = extractInputFieldsFromBlocks(blocksRecord) - const response = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - color: workflowData.color, - folderId: workflowData.folderId, - workspaceId: workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() || null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() || null, - variables: workflowData.variables || {}, - inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), - } + const response = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + color: workflowData.color, + folderId: workflowData.folderId, + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() || null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() || null, + variables: workflowData.variables || {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } - const limits = await getUserLimits(userId) + const limits = await getUserLimits(userId) - const apiResponse = createApiResponse({ data: response }, limits, rateLimit) + const apiResponse = createApiResponse({ data: response }, limits, rateLimit) - return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] Workflow details fetch error`, { error: message }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json(apiResponse.body, { headers: apiResponse.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Workflow details fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 718f0afb37f..1468661e3ca 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -5,6 +5,7 @@ import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -40,7 +41,7 @@ function decodeCursor(cursor: string): CursorData | null { } } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -175,4 +176,4 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Workflows fetch error`, { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 87e988e04e3..7b692368bd6 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -10,6 +10,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' import { env } from '@/lib/core/config/env' import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enrichTableSchema } from '@/lib/table/llm/wand' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils' @@ -156,7 +157,7 @@ async function updateUserStatsForWand( } } -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() logger.info(`[${requestId}] Received wand generation request`) @@ -610,4 +611,4 @@ export async function POST(req: NextRequest) { { status } ) } -} +}) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 24d93fc0609..111adfbd9db 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -8,6 +8,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateInteger } from '@/lib/core/security/input-validation' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -17,283 +18,286 @@ const logger = createLogger('WebhookAPI') export const dynamic = 'force-dynamic' // Get a specific webhook -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - try { - const { id } = await params + try { + const { id } = await params - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const webhooks = await db - .select({ - webhook: webhook, - workflow: { - id: workflow.id, - name: workflow.name, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) - .limit(1) - - if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${id}`) - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const webhooks = await db + .select({ + webhook: webhook, + workflow: { + id: workflow.id, + name: workflow.name, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) + .limit(1) + + if (webhooks.length === 0) { + logger.warn(`[${requestId}] Webhook not found: ${id}`) + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } - const webhookData = webhooks[0] + const webhookData = webhooks[0] - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: webhookData.workflow.id, - userId, - action: 'read', - }) - const hasAccess = authorization.allowed + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: webhookData.workflow.id, + userId, + action: 'read', + }) + const hasAccess = authorization.allowed - if (!hasAccess) { - logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + if (!hasAccess) { + logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - logger.info(`[${requestId}] Successfully retrieved webhook: ${id}`) - return NextResponse.json({ webhook: webhooks[0] }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching webhook`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.info(`[${requestId}] Successfully retrieved webhook: ${id}`) + return NextResponse.json({ webhook: webhooks[0] }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error fetching webhook`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - try { - const { id } = await params + try { + const { id } = await params - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized webhook update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized webhook update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const body = await request.json() + const { isActive, failedCount } = body - const body = await request.json() - const { isActive, failedCount } = body + if (failedCount !== undefined) { + const validation = validateInteger(failedCount, 'failedCount', { min: 0 }) + if (!validation.isValid) { + logger.warn(`[${requestId}] ${validation.error}`) + return NextResponse.json({ error: validation.error }, { status: 400 }) + } + } + + const webhooks = await db + .select({ + webhook: webhook, + workflow: { + id: workflow.id, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) + .limit(1) - if (failedCount !== undefined) { - const validation = validateInteger(failedCount, 'failedCount', { min: 0 }) - if (!validation.isValid) { - logger.warn(`[${requestId}] ${validation.error}`) - return NextResponse.json({ error: validation.error }, { status: 400 }) + if (webhooks.length === 0) { + logger.warn(`[${requestId}] Webhook not found: ${id}`) + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) } - } - const webhooks = await db - .select({ - webhook: webhook, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, + const webhookData = webhooks[0] + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: webhookData.workflow.id, + userId, + action: 'write', }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) - .limit(1) - - if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${id}`) - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } + const canModify = authorization.allowed - const webhookData = webhooks[0] - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: webhookData.workflow.id, - userId, - action: 'write', - }) - const canModify = authorization.allowed - - if (!canModify) { - logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + if (!canModify) { + logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } - const updatedWebhook = await db - .update(webhook) - .set({ - isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive, - failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount, - updatedAt: new Date(), - }) - .where(eq(webhook.id, id)) - .returning() - - logger.info(`[${requestId}] Successfully updated webhook: ${id}`) - return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error updating webhook`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + const updatedWebhook = await db + .update(webhook) + .set({ + isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive, + failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount, + updatedAt: new Date(), + }) + .where(eq(webhook.id, id)) + .returning() + + logger.info(`[${requestId}] Successfully updated webhook: ${id}`) + return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 }) + } catch (error) { + logger.error(`[${requestId}] Error updating webhook`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) // Delete a webhook -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - - try { - const { id } = await params - - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized webhook deletion attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - // Find the webhook and check permissions - const webhooks = await db - .select({ - webhook: webhook, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(eq(webhook.id, id)) - .limit(1) - - if (webhooks.length === 0) { - logger.warn(`[${requestId}] Webhook not found: ${id}`) - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - const webhookData = webhooks[0] + try { + const { id } = await params - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: webhookData.workflow.id, - userId, - action: 'write', - }) - const canDelete = authorization.allowed + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized webhook deletion attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + // Find the webhook and check permissions + const webhooks = await db + .select({ + webhook: webhook, + workflow: { + id: workflow.id, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(eq(webhook.id, id)) + .limit(1) - if (!canDelete) { - logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + if (webhooks.length === 0) { + logger.warn(`[${requestId}] Webhook not found: ${id}`) + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } - const foundWebhook = webhookData.webhook - const credentialSetId = foundWebhook.credentialSetId as string | undefined - const blockId = foundWebhook.blockId as string | undefined + const webhookData = webhooks[0] - if (credentialSetId && blockId) { - const allCredentialSetWebhooks = await db - .select() - .from(webhook) - .where( - and( - eq(webhook.workflowId, webhookData.workflow.id), - eq(webhook.blockId, blockId), - isNull(webhook.archivedAt) + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: webhookData.workflow.id, + userId, + action: 'write', + }) + const canDelete = authorization.allowed + + if (!canDelete) { + logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const foundWebhook = webhookData.webhook + const credentialSetId = foundWebhook.credentialSetId as string | undefined + const blockId = foundWebhook.blockId as string | undefined + + if (credentialSetId && blockId) { + const allCredentialSetWebhooks = await db + .select() + .from(webhook) + .where( + and( + eq(webhook.workflowId, webhookData.workflow.id), + eq(webhook.blockId, blockId), + isNull(webhook.archivedAt) + ) ) + + const webhooksToDelete = allCredentialSetWebhooks.filter( + (w) => w.credentialSetId === credentialSetId ) - const webhooksToDelete = allCredentialSetWebhooks.filter( - (w) => w.credentialSetId === credentialSetId - ) + for (const w of webhooksToDelete) { + await cleanupExternalWebhook(w, webhookData.workflow, requestId) + } - for (const w of webhooksToDelete) { - await cleanupExternalWebhook(w, webhookData.workflow, requestId) - } + const idsToDelete = webhooksToDelete.map((w) => w.id) + for (const wId of idsToDelete) { + await db.delete(webhook).where(eq(webhook.id, wId)) + } - const idsToDelete = webhooksToDelete.map((w) => w.id) - for (const wId of idsToDelete) { - await db.delete(webhook).where(eq(webhook.id, wId)) - } + try { + for (const wId of idsToDelete) { + PlatformEvents.webhookDeleted({ + webhookId: wId, + workflowId: webhookData.workflow.id, + }) + } + } catch { + // Telemetry should not fail the operation + } - try { - for (const wId of idsToDelete) { + logger.info( + `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`, + { + credentialSetId, + blockId, + deletedIds: idsToDelete, + } + ) + } else { + await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) + await db.delete(webhook).where(eq(webhook.id, id)) + + try { PlatformEvents.webhookDeleted({ - webhookId: wId, + webhookId: id, workflowId: webhookData.workflow.id, }) + } catch { + // Telemetry should not fail the operation } - } catch { - // Telemetry should not fail the operation + + logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) } - logger.info( - `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`, + recordAudit({ + workspaceId: webhookData.workflow.workspaceId || null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.WEBHOOK_DELETED, + resourceType: AuditResourceType.WEBHOOK, + resourceId: id, + resourceName: foundWebhook.provider || 'generic', + description: 'Deleted webhook', + metadata: { workflowId: webhookData.workflow.id }, + request, + }) + + const wsId = webhookData.workflow.workspaceId || undefined + captureServerEvent( + userId, + 'webhook_trigger_deleted', { - credentialSetId, - blockId, - deletedIds: idsToDelete, - } + webhook_id: id, + workflow_id: webhookData.workflow.id, + provider: foundWebhook.provider || 'generic', + workspace_id: wsId ?? '', + }, + wsId ? { groups: { workspace: wsId } } : undefined ) - } else { - await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) - await db.delete(webhook).where(eq(webhook.id, id)) - - try { - PlatformEvents.webhookDeleted({ - webhookId: id, - workflowId: webhookData.workflow.id, - }) - } catch { - // Telemetry should not fail the operation - } - logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error: any) { + logger.error(`[${requestId}] Error deleting webhook`, { + error: error.message, + stack: error.stack, + }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - recordAudit({ - workspaceId: webhookData.workflow.workspaceId || null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WEBHOOK_DELETED, - resourceType: AuditResourceType.WEBHOOK, - resourceId: id, - resourceName: foundWebhook.provider || 'generic', - description: 'Deleted webhook', - metadata: { workflowId: webhookData.workflow.id }, - request, - }) - - const wsId = webhookData.workflow.workspaceId || undefined - captureServerEvent( - userId, - 'webhook_trigger_deleted', - { - webhook_id: id, - workflow_id: webhookData.workflow.id, - provider: foundWebhook.provider || 'generic', - workspace_id: wsId ?? '', - }, - wsId ? { groups: { workspace: wsId } } : undefined - ) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error: any) { - logger.error(`[${requestId}] Error deleting webhook`, { - error: error.message, - stack: error.stack, - }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index 25e87f1de35..e86729d2ff3 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -14,6 +14,7 @@ import { NextResponse } from 'next/server' import { Webhook } from 'svix' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeInboxTask } from '@/lib/mothership/inbox/executor' import type { AgentMailWebhookPayload, RejectionReason } from '@/lib/mothership/inbox/types' @@ -22,7 +23,7 @@ const logger = createLogger('AgentMailWebhook') const AUTOMATED_SENDERS = ['mailer-daemon@', 'noreply@', 'no-reply@', 'postmaster@'] const MAX_EMAILS_PER_HOUR = 20 -export async function POST(req: Request) { +export const POST = withRouteHandler(async (req: Request) => { try { const rawBody = await req.text() const svixId = req.headers.get('svix-id') @@ -202,7 +203,7 @@ export async function POST(req: Request) { }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) async function isSenderAllowed(email: string, workspaceId: string): Promise { const [allowedSenderResult, memberResult] = await Promise.all([ diff --git a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts index a4608033454..2d4312b54be 100644 --- a/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts +++ b/apps/sim/app/api/webhooks/cleanup/idempotency/route.ts @@ -3,13 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { cleanupExpiredIdempotencyKeys, getIdempotencyKeyStats } from '@/lib/core/idempotency' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('IdempotencyCleanupAPI') export const dynamic = 'force-dynamic' export const maxDuration = 300 // Allow up to 5 minutes for cleanup -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() logger.info(`Idempotency cleanup triggered (${requestId})`) @@ -61,4 +62,4 @@ export async function GET(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.ts index d314e8563bb..3f4366a31c8 100644 --- a/apps/sim/app/api/webhooks/poll/[provider]/route.ts +++ b/apps/sim/app/api/webhooks/poll/[provider]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollProvider, VALID_POLLING_PROVIDERS } from '@/lib/webhooks/polling' const logger = createLogger('PollingAPI') @@ -13,10 +14,10 @@ const LOCK_TTL_SECONDS = 180 export const dynamic = 'force-dynamic' export const maxDuration = 180 -export async function GET( +export const GET = withRouteHandler(async ( request: NextRequest, { params }: { params: Promise<{ provider: string }> } -) { +) => { const { provider } = await params const requestId = generateShortId() @@ -70,4 +71,4 @@ export async function GET( await releaseLock(LOCK_KEY, lockValue).catch(() => {}) } } -} +}) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index c6ef9e992e1..67409d8e6a4 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { generateId, generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getProviderIdFromServiceId } from '@/lib/oauth' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' @@ -56,7 +57,7 @@ async function revertSavedWebhook( } // Get all webhooks for the current user -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -168,10 +169,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Error fetching webhooks`, error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) // Create or Update a webhook -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const session = await getSession() const userId = session?.user?.id @@ -714,4 +715,4 @@ export async function POST(request: NextRequest) { }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 46ec98d4735..ed523a0af24 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { DispatchQueueFullError } from '@/lib/core/workspace-dispatch' import { checkWebhookPreprocessing, @@ -23,37 +24,38 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 -export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string }> }) { - const requestId = generateRequestId() - const { path } = await params - - // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.) - const challengeResponse = await handleProviderChallenges({}, request, requestId, path) - if (challengeResponse) { - return challengeResponse - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ path: string }> }) => { + const requestId = generateRequestId() + const { path } = await params - return ( - (await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) || - new NextResponse('Method not allowed', { status: 405 }) - ) -} + // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.) + const challengeResponse = await handleProviderChallenges({}, request, requestId, path) + if (challengeResponse) { + return challengeResponse + } -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ path: string }> } -) { - const ticket = tryAdmit() - if (!ticket) { - return admissionRejectedResponse() + return ( + (await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) || + new NextResponse('Method not allowed', { status: 405 }) + ) } +) + +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ path: string }> }) => { + const ticket = tryAdmit() + if (!ticket) { + return admissionRejectedResponse() + } - try { - return await handleWebhookPost(request, params) - } finally { - ticket.release() + try { + return await handleWebhookPost(request, params) + } finally { + ticket.release() + } } -} +) async function handleWebhookPost( request: NextRequest, diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 907e1ea0492..6893a3192f4 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { applyAutoLayout } from '@/lib/workflows/autolayout' import { DEFAULT_HORIZONTAL_SPACING, @@ -46,137 +47,139 @@ const AutoLayoutRequestSchema = z.object({ * POST /api/workflows/[id]/autolayout * Apply autolayout to an existing workflow */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = auth.userId - - const body = await request.json() - const layoutOptions = AutoLayoutRequestSchema.parse(body) +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const startTime = Date.now() + const { id: workflowId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { - userId, - }) + const userId = auth.userId - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow + const body = await request.json() + const layoutOptions = AutoLayoutRequestSchema.parse(body) - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + logger.info(`[${requestId}] Processing autolayout request for workflow ${workflowId}`, { + userId, + }) - const canUpdate = authorization.allowed + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + const workflowData = authorization.workflow - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } + if (!workflowData) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } - let currentWorkflowData: NormalizedWorkflowData | null + const canUpdate = authorization.allowed - if (layoutOptions.blocks && layoutOptions.edges) { - logger.info(`[${requestId}] Using provided blocks with live measurements`) - currentWorkflowData = { - blocks: layoutOptions.blocks, - edges: layoutOptions.edges, - loops: layoutOptions.loops || {}, - parallels: layoutOptions.parallels || {}, - isFromNormalizedTables: false, + if (!canUpdate) { + logger.warn( + `[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}` + ) + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status || 403 } + ) } - } else { - logger.info(`[${requestId}] Loading blocks from database`) - currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) - } - if (!currentWorkflowData) { - logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) - return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) - } - - const autoLayoutOptions = { - horizontalSpacing: layoutOptions.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, - verticalSpacing: layoutOptions.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, - padding: { - x: layoutOptions.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, - y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, - }, - alignment: layoutOptions.alignment, - gridSize: layoutOptions.gridSize, - } + let currentWorkflowData: NormalizedWorkflowData | null + + if (layoutOptions.blocks && layoutOptions.edges) { + logger.info(`[${requestId}] Using provided blocks with live measurements`) + currentWorkflowData = { + blocks: layoutOptions.blocks, + edges: layoutOptions.edges, + loops: layoutOptions.loops || {}, + parallels: layoutOptions.parallels || {}, + isFromNormalizedTables: false, + } + } else { + logger.info(`[${requestId}] Loading blocks from database`) + currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) + } - const layoutResult = applyAutoLayout( - currentWorkflowData.blocks, - currentWorkflowData.edges, - autoLayoutOptions - ) + if (!currentWorkflowData) { + logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) + return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) + } - if (!layoutResult.success || !layoutResult.blocks) { - logger.error(`[${requestId}] Auto layout failed:`, { - error: layoutResult.error, - }) - return NextResponse.json( - { - error: 'Auto layout failed', - details: layoutResult.error || 'Unknown error', + const autoLayoutOptions = { + horizontalSpacing: layoutOptions.spacing?.horizontal ?? DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: layoutOptions.spacing?.vertical ?? DEFAULT_VERTICAL_SPACING, + padding: { + x: layoutOptions.padding?.x ?? DEFAULT_LAYOUT_PADDING.x, + y: layoutOptions.padding?.y ?? DEFAULT_LAYOUT_PADDING.y, }, - { status: 500 } + alignment: layoutOptions.alignment, + gridSize: layoutOptions.gridSize, + } + + const layoutResult = applyAutoLayout( + currentWorkflowData.blocks, + currentWorkflowData.edges, + autoLayoutOptions ) - } - const elapsed = Date.now() - startTime - const blockCount = Object.keys(layoutResult.blocks).length + if (!layoutResult.success || !layoutResult.blocks) { + logger.error(`[${requestId}] Auto layout failed:`, { + error: layoutResult.error, + }) + return NextResponse.json( + { + error: 'Auto layout failed', + details: layoutResult.error || 'Unknown error', + }, + { status: 500 } + ) + } - logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, { - blockCount, - workflowId, - }) + const elapsed = Date.now() - startTime + const blockCount = Object.keys(layoutResult.blocks).length - return NextResponse.json({ - success: true, - message: `Autolayout applied successfully to ${blockCount} blocks`, - data: { + logger.info(`[${requestId}] Autolayout completed successfully in ${elapsed}ms`, { blockCount, - elapsed: `${elapsed}ms`, - layoutedBlocks: layoutResult.blocks, - }, - }) - } catch (error) { - const elapsed = Date.now() - startTime + workflowId, + }) + + return NextResponse.json({ + success: true, + message: `Autolayout applied successfully to ${blockCount} blocks`, + data: { + blockCount, + elapsed: `${elapsed}ms`, + layoutedBlocks: layoutResult.blocks, + }, + }) + } catch (error) { + const elapsed = Date.now() - startTime + + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid autolayout request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid autolayout request data`, { errors: error.errors }) + logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error) return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } + { + error: 'Autolayout failed', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } ) } - - logger.error(`[${requestId}] Autolayout failed after ${elapsed}ms:`, error) - return NextResponse.json( - { - error: 'Autolayout failed', - details: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts index 22d9c7d5532..334f87ef727 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -13,68 +14,70 @@ const logger = createLogger('ChatStatusAPI') /** * GET endpoint to check if a workflow has an active chat deployment */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const requestId = generateRequestId() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const requestId = generateRequestId() - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return createErrorResponse('Unauthorized', 401) - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: id, - userId: auth.userId, - action: 'read', - }) - if (!authorization.allowed) { - return createErrorResponse( - authorization.message || 'Access denied', - authorization.status || 403 - ) - } + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return createErrorResponse('Unauthorized', 401) + } - // Find any active chat deployments for this workflow - const deploymentResults = await db - .select({ - id: chat.id, - identifier: chat.identifier, - title: chat.title, - description: chat.description, - customizations: chat.customizations, - authType: chat.authType, - allowedEmails: chat.allowedEmails, - outputConfigs: chat.outputConfigs, - password: chat.password, - isActive: chat.isActive, + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: auth.userId, + action: 'read', }) - .from(chat) - .where(and(eq(chat.workflowId, id), isNull(chat.archivedAt))) - .limit(1) + if (!authorization.allowed) { + return createErrorResponse( + authorization.message || 'Access denied', + authorization.status || 403 + ) + } - const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive - const deploymentInfo = - deploymentResults.length > 0 - ? { - id: deploymentResults[0].id, - identifier: deploymentResults[0].identifier, - title: deploymentResults[0].title, - description: deploymentResults[0].description, - customizations: deploymentResults[0].customizations, - authType: deploymentResults[0].authType, - allowedEmails: deploymentResults[0].allowedEmails, - outputConfigs: deploymentResults[0].outputConfigs, - hasPassword: Boolean(deploymentResults[0].password), - } - : null + // Find any active chat deployments for this workflow + const deploymentResults = await db + .select({ + id: chat.id, + identifier: chat.identifier, + title: chat.title, + description: chat.description, + customizations: chat.customizations, + authType: chat.authType, + allowedEmails: chat.allowedEmails, + outputConfigs: chat.outputConfigs, + password: chat.password, + isActive: chat.isActive, + }) + .from(chat) + .where(and(eq(chat.workflowId, id), isNull(chat.archivedAt))) + .limit(1) - return createSuccessResponse({ - isDeployed, - deployment: deploymentInfo, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error checking chat deployment status:`, error) - return createErrorResponse(error.message || 'Failed to check chat deployment status', 500) + const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive + const deploymentInfo = + deploymentResults.length > 0 + ? { + id: deploymentResults[0].id, + identifier: deploymentResults[0].identifier, + title: deploymentResults[0].title, + description: deploymentResults[0].description, + customizations: deploymentResults[0].customizations, + authType: deploymentResults[0].authType, + allowedEmails: deploymentResults[0].allowedEmails, + outputConfigs: deploymentResults[0].outputConfigs, + hasPassword: Boolean(deploymentResults[0].password), + } + : null + + return createSuccessResponse({ + isDeployed, + deployment: deploymentInfo, + }) + } catch (error: any) { + logger.error(`[${requestId}] Error checking chat deployment status:`, error) + return createErrorResponse(error.message || 'Failed to check chat deployment status', 500) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index e1130d42ffe..64172ab8cc3 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' @@ -17,211 +18,219 @@ const logger = createLogger('WorkflowDeployAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const { error, workflow: workflowData } = await validateWorkflowPermissions( - id, - requestId, - 'read' - ) - if (error) { - return createErrorResponse(error.message, error.status) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const { error, workflow: workflowData } = await validateWorkflowPermissions( + id, + requestId, + 'read' + ) + if (error) { + return createErrorResponse(error.message, error.status) + } + + if (!workflowData.isDeployed) { + logger.info(`[${requestId}] Workflow is not deployed: ${id}`) + return createSuccessResponse({ + isDeployed: false, + deployedAt: null, + apiKey: null, + needsRedeployment: false, + isPublicApi: workflowData.isPublicApi ?? false, + }) + } + + const needsRedeployment = await checkNeedsRedeployment(id) + + logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) + + const responseApiKeyInfo = workflowData.workspaceId + ? 'Workspace API keys' + : 'Personal API keys' - if (!workflowData.isDeployed) { - logger.info(`[${requestId}] Workflow is not deployed: ${id}`) return createSuccessResponse({ - isDeployed: false, - deployedAt: null, - apiKey: null, - needsRedeployment: false, + apiKey: responseApiKeyInfo, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt, + needsRedeployment, isPublicApi: workflowData.isPublicApi ?? false, }) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) + return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) } + } +) + +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') + if (error) { + return createErrorResponse(error.message, error.status) + } - const needsRedeployment = await checkNeedsRedeployment(id) - - logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) + const actorUserId: string | null = session?.user?.id ?? null + if (!actorUserId) { + logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) + return createErrorResponse('Unable to determine deploying user', 400) + } - const responseApiKeyInfo = workflowData.workspaceId ? 'Workspace API keys' : 'Personal API keys' + const result = await performFullDeploy({ + workflowId: id, + userId: actorUserId, + workflowName: workflowData!.name || undefined, + requestId, + request, + }) - return createSuccessResponse({ - apiKey: responseApiKeyInfo, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt, - needsRedeployment, - isPublicApi: workflowData.isPublicApi ?? false, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) - return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) - } -} - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } + if (!result.success) { + const status = + result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 + return createErrorResponse(result.error || 'Failed to deploy workflow', status) + } - const actorUserId: string | null = session?.user?.id ?? null - if (!actorUserId) { - logger.warn(`[${requestId}] Unable to resolve actor user for workflow deployment: ${id}`) - return createErrorResponse('Unable to determine deploying user', 400) - } + logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) - const result = await performFullDeploy({ - workflowId: id, - userId: actorUserId, - workflowName: workflowData!.name || undefined, - requestId, - request, - }) - - if (!result.success) { - const status = - result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500 - return createErrorResponse(result.error || 'Failed to deploy workflow', status) - } + captureServerEvent( + actorUserId, + 'workflow_deployed', + { workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' }, + { + groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined, + setOnce: { first_workflow_deployed_at: new Date().toISOString() }, + } + ) - logger.info(`[${requestId}] Workflow deployed successfully: ${id}`) + const responseApiKeyInfo = workflowData!.workspaceId + ? 'Workspace API keys' + : 'Personal API keys' - captureServerEvent( - actorUserId, - 'workflow_deployed', - { workflow_id: id, workspace_id: workflowData!.workspaceId ?? '' }, - { - groups: workflowData!.workspaceId ? { workspace: workflowData!.workspaceId } : undefined, - setOnce: { first_workflow_deployed_at: new Date().toISOString() }, - } - ) - - const responseApiKeyInfo = workflowData!.workspaceId - ? 'Workspace API keys' - : 'Personal API keys' - - return createSuccessResponse({ - apiKey: responseApiKeyInfo, - isDeployed: true, - deployedAt: result.deployedAt, - warnings: result.warnings, - }) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Failed to deploy workflow' - logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) - return createErrorResponse(message, 500) - } -} - -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) + return createSuccessResponse({ + apiKey: responseApiKeyInfo, + isDeployed: true, + deployedAt: result.deployedAt, + warnings: result.warnings, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to deploy workflow' + logger.error(`[${requestId}] Error deploying workflow: ${id}`, { error }) + return createErrorResponse(message, 500) } + } +) + +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') + if (error) { + return createErrorResponse(error.message, error.status) + } - const body = await request.json() - const { isPublicApi } = body + const body = await request.json() + const { isPublicApi } = body - if (typeof isPublicApi !== 'boolean') { - return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400) - } + if (typeof isPublicApi !== 'boolean') { + return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400) + } - if (isPublicApi) { - const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import( - '@/ee/access-control/utils/permission-check' - ) - try { - await validatePublicApiAllowed(session?.user?.id) - } catch (err) { - if (err instanceof PublicApiNotAllowedError) { - return createErrorResponse('Public API access is disabled', 403) + if (isPublicApi) { + const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import( + '@/ee/access-control/utils/permission-check' + ) + try { + await validatePublicApiAllowed(session?.user?.id) + } catch (err) { + if (err instanceof PublicApiNotAllowedError) { + return createErrorResponse('Public API access is disabled', 403) + } + throw err } - throw err } - } - await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id)) + await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id)) - logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) + logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`) - const wsId = workflowData?.workspaceId - captureServerEvent( - session!.user.id, - 'workflow_public_api_toggled', - { workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi }, - wsId ? { groups: { workspace: wsId } } : undefined - ) + const wsId = workflowData?.workspaceId + captureServerEvent( + session!.user.id, + 'workflow_public_api_toggled', + { workflow_id: id, workspace_id: wsId ?? '', is_public: isPublicApi }, + wsId ? { groups: { workspace: wsId } } : undefined + ) - return createSuccessResponse({ isPublicApi }) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Failed to update deployment settings' - logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error }) - return createErrorResponse(message, 500) - } -} - -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) + return createSuccessResponse({ isPublicApi }) + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : 'Failed to update deployment settings' + logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error }) + return createErrorResponse(message, 500) } + } +) + +export const DELETE = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') + if (error) { + return createErrorResponse(error.message, error.status) + } - const result = await performFullUndeploy({ - workflowId: id, - userId: session!.user.id, - requestId, - }) + const result = await performFullUndeploy({ + workflowId: id, + userId: session!.user.id, + requestId, + }) - if (!result.success) { - return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) - } + if (!result.success) { + return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) + } + + const wsId = workflowData?.workspaceId + captureServerEvent( + session!.user.id, + 'workflow_undeployed', + { workflow_id: id, workspace_id: wsId ?? '' }, + wsId ? { groups: { workspace: wsId } } : undefined + ) - const wsId = workflowData?.workspaceId - captureServerEvent( - session!.user.id, - 'workflow_undeployed', - { workflow_id: id, workspace_id: wsId ?? '' }, - wsId ? { groups: { workspace: wsId } } : undefined - ) - - return createSuccessResponse({ - isDeployed: false, - deployedAt: null, - apiKey: null, - }) - } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Failed to undeploy workflow' - logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) - return createErrorResponse(message, 500) + return createSuccessResponse({ + isDeployed: false, + deployedAt: null, + apiKey: null, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to undeploy workflow' + logger.error(`[${requestId}] Error undeploying workflow: ${id}`, { error }) + return createErrorResponse(message, 500) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index 347e77eacb9..48b33f5e816 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest, NextResponse } from 'next/server' import { verifyInternalToken } from '@/lib/auth/internal' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -16,48 +17,50 @@ function addNoCacheHeaders(response: NextResponse): NextResponse { return response } -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - const authHeader = request.headers.get('authorization') - let isInternalCall = false + try { + const authHeader = request.headers.get('authorization') + let isInternalCall = false - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1] - const verification = await verifyInternalToken(token) - isInternalCall = verification.valid - } + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1] + const verification = await verifyInternalToken(token) + isInternalCall = verification.valid + } - if (!isInternalCall) { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - const response = createErrorResponse(error.message, error.status) - return addNoCacheHeaders(response) + if (!isInternalCall) { + const { error } = await validateWorkflowPermissions(id, requestId, 'read') + if (error) { + const response = createErrorResponse(error.message, error.status) + return addNoCacheHeaders(response) + } } - } - let deployedState = null - try { - const data = await loadDeployedWorkflowState(id) - deployedState = { - blocks: data.blocks, - edges: data.edges, - loops: data.loops, - parallels: data.parallels, - variables: data.variables, + let deployedState = null + try { + const data = await loadDeployedWorkflowState(id) + deployedState = { + blocks: data.blocks, + edges: data.edges, + loops: data.loops, + parallels: data.parallels, + variables: data.variables, + } + } catch (error) { + logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error }) + deployedState = null } - } catch (error) { - logger.warn(`[${requestId}] Failed to load deployed state for workflow ${id}`, { error }) - deployedState = null - } - const response = createSuccessResponse({ deployedState }) - return addNoCacheHeaders(response) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error) - const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500) - return addNoCacheHeaders(response) + const response = createSuccessResponse({ deployedState }) + return addNoCacheHeaders(response) + } catch (error: any) { + logger.error(`[${requestId}] Error fetching deployed state: ${id}`, error) + const response = createErrorResponse(error.message || 'Failed to fetch deployed state', 500) + return addNoCacheHeaders(response) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index a209db29eb4..51c6f4ecf18 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -5,6 +5,7 @@ import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' @@ -15,127 +16,129 @@ const logger = createLogger('RevertToDeploymentVersionAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } -) { - const requestId = generateRequestId() - const { id, version } = await params +export const POST = withRouteHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string; version: string }> } + ) => { + const requestId = generateRequestId() + const { id, version } = await params - try { - const { - error, - session, - workflow: workflowRecord, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) - } + try { + const { + error, + session, + workflow: workflowRecord, + } = await validateWorkflowPermissions(id, requestId, 'admin') + if (error) { + return createErrorResponse(error.message, error.status) + } - const versionSelector = version === 'active' ? null : Number(version) - if (version !== 'active' && !Number.isFinite(versionSelector)) { - return createErrorResponse('Invalid version', 400) - } + const versionSelector = version === 'active' ? null : Number(version) + if (version !== 'active' && !Number.isFinite(versionSelector)) { + return createErrorResponse('Invalid version', 400) + } - let stateRow: { state: any } | null = null - if (version === 'active') { - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) + let stateRow: { state: any } | null = null + if (version === 'active') { + const [row] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.isActive, true) + ) ) - ) - .limit(1) - stateRow = row || null - } else { - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionSelector as number) + .limit(1) + stateRow = row || null + } else { + const [row] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.version, versionSelector as number) + ) ) - ) - .limit(1) - stateRow = row || null - } + .limit(1) + stateRow = row || null + } - if (!stateRow?.state) { - return createErrorResponse('Deployment version not found', 404) - } + if (!stateRow?.state) { + return createErrorResponse('Deployment version not found', 404) + } - const deployedState = stateRow.state - if (!deployedState.blocks || !deployedState.edges) { - return createErrorResponse('Invalid deployed state structure', 500) - } + const deployedState = stateRow.state + if (!deployedState.blocks || !deployedState.edges) { + return createErrorResponse('Invalid deployed state structure', 500) + } - const saveResult = await saveWorkflowToNormalizedTables(id, { - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops || {}, - parallels: deployedState.parallels || {}, - lastSaved: Date.now(), - }) + const saveResult = await saveWorkflowToNormalizedTables(id, { + blocks: deployedState.blocks, + edges: deployedState.edges, + loops: deployedState.loops || {}, + parallels: deployedState.parallels || {}, + lastSaved: Date.now(), + }) - if (!saveResult.success) { - return createErrorResponse(saveResult.error || 'Failed to save deployed state', 500) - } + if (!saveResult.success) { + return createErrorResponse(saveResult.error || 'Failed to save deployed state', 500) + } - await db - .update(workflow) - .set({ lastSynced: new Date(), updatedAt: new Date() }) - .where(eq(workflow.id, id)) + await db + .update(workflow) + .set({ lastSynced: new Date(), updatedAt: new Date() }) + .where(eq(workflow.id, id)) - try { - const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' - await fetch(`${socketServerUrl}/api/workflow-reverted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId: id, timestamp: Date.now() }), - }) - } catch (e) { - logger.error('Error sending workflow reverted event to socket server', e) - } + try { + const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' + await fetch(`${socketServerUrl}/api/workflow-reverted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId: id, timestamp: Date.now() }), + }) + } catch (e) { + logger.error('Error sending workflow reverted event to socket server', e) + } - captureServerEvent( - session!.user.id, - 'workflow_deployment_reverted', - { - workflow_id: id, - workspace_id: workflowRecord?.workspaceId ?? '', - version, - }, - workflowRecord?.workspaceId - ? { groups: { workspace: workflowRecord.workspaceId } } - : undefined - ) + captureServerEvent( + session!.user.id, + 'workflow_deployment_reverted', + { + workflow_id: id, + workspace_id: workflowRecord?.workspaceId ?? '', + version, + }, + workflowRecord?.workspaceId + ? { groups: { workspace: workflowRecord.workspaceId } } + : undefined + ) - recordAudit({ - workspaceId: workflowRecord?.workspaceId ?? null, - actorId: session!.user.id, - action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: id, - actorName: session!.user.name ?? undefined, - actorEmail: session!.user.email ?? undefined, - resourceName: workflowRecord?.name ?? undefined, - description: `Reverted workflow to deployment version ${version}`, - request, - }) + recordAudit({ + workspaceId: workflowRecord?.workspaceId ?? null, + actorId: session!.user.id, + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: id, + actorName: session!.user.name ?? undefined, + actorEmail: session!.user.email ?? undefined, + resourceName: workflowRecord?.name ?? undefined, + description: `Reverted workflow to deployment version ${version}`, + request, + }) - return createSuccessResponse({ - message: 'Reverted to deployment version', - lastSaved: Date.now(), - }) - } catch (error: any) { - logger.error('Error reverting to deployment version', error) - return createErrorResponse(error.message || 'Failed to revert', 500) + return createSuccessResponse({ + message: 'Reverted to deployment version', + lastSaved: Date.now(), + }) + } catch (error: any) { + logger.error('Error reverting to deployment version', error) + return createErrorResponse(error.message || 'Failed to revert', 500) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 74fd68d137f..59039f21737 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -4,6 +4,7 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' @@ -37,200 +38,212 @@ const patchBodySchema = z export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } -) { - const requestId = generateRequestId() - const { id, version } = await params - - try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) - } +export const GET = withRouteHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string; version: string }> } + ) => { + const requestId = generateRequestId() + const { id, version } = await params + + try { + const { error } = await validateWorkflowPermissions(id, requestId, 'read') + if (error) { + return createErrorResponse(error.message, error.status) + } - const versionNum = Number(version) - if (!Number.isFinite(versionNum)) { - return createErrorResponse('Invalid version', 400) - } + const versionNum = Number(version) + if (!Number.isFinite(versionNum)) { + return createErrorResponse('Invalid version', 400) + } - const [row] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) + const [row] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.version, versionNum) + ) ) - ) - .limit(1) - - if (!row?.state) { - return createErrorResponse('Deployment version not found', 404) - } - - return createSuccessResponse({ deployedState: row.state }) - } catch (error: any) { - logger.error( - `[${requestId}] Error fetching deployment version ${version} for workflow ${id}`, - error - ) - return createErrorResponse(error.message || 'Failed to fetch deployment version', 500) - } -} - -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ id: string; version: string }> } -) { - const requestId = generateRequestId() - const { id, version } = await params + .limit(1) - try { - const body = await request.json() - const validation = patchBodySchema.safeParse(body) - - if (!validation.success) { - return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400) - } - - const { name, description, isActive } = validation.data - - // Activation requires admin permission, other updates require write - const requiredPermission = isActive ? 'admin' : 'write' - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(id, requestId, requiredPermission) - if (error) { - return createErrorResponse(error.message, error.status) - } + if (!row?.state) { + return createErrorResponse('Deployment version not found', 404) + } - const versionNum = Number(version) - if (!Number.isFinite(versionNum)) { - return createErrorResponse('Invalid version', 400) + return createSuccessResponse({ deployedState: row.state }) + } catch (error: any) { + logger.error( + `[${requestId}] Error fetching deployment version ${version} for workflow ${id}`, + error + ) + return createErrorResponse(error.message || 'Failed to fetch deployment version', 500) } - - // Handle activation - if (isActive) { - const actorUserId = session?.user?.id - if (!actorUserId) { - logger.warn(`[${requestId}] Unable to resolve actor user for deployment activation: ${id}`) - return createErrorResponse('Unable to determine activating user', 400) + } +) + +export const PATCH = withRouteHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string; version: string }> } + ) => { + const requestId = generateRequestId() + const { id, version } = await params + + try { + const body = await request.json() + const validation = patchBodySchema.safeParse(body) + + if (!validation.success) { + return createErrorResponse( + validation.error.errors[0]?.message || 'Invalid request body', + 400 + ) } - const activateResult = await performActivateVersion({ - workflowId: id, - version: versionNum, - userId: actorUserId, - workflow: workflowData as Record, - requestId, - request, - }) + const { name, description, isActive } = validation.data + + // Activation requires admin permission, other updates require write + const requiredPermission = isActive ? 'admin' : 'write' + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, requiredPermission) + if (error) { + return createErrorResponse(error.message, error.status) + } - if (!activateResult.success) { - const status = - activateResult.errorCode === 'not_found' - ? 404 - : activateResult.errorCode === 'validation' - ? 400 - : 500 - return createErrorResponse(activateResult.error || 'Failed to activate deployment', status) + const versionNum = Number(version) + if (!Number.isFinite(versionNum)) { + return createErrorResponse('Invalid version', 400) } - let updatedName: string | null | undefined - let updatedDescription: string | null | undefined - if (name !== undefined || description !== undefined) { - const activationUpdateData: { name?: string; description?: string | null } = {} - if (name !== undefined) { - activationUpdateData.name = name + // Handle activation + if (isActive) { + const actorUserId = session?.user?.id + if (!actorUserId) { + logger.warn( + `[${requestId}] Unable to resolve actor user for deployment activation: ${id}` + ) + return createErrorResponse('Unable to determine activating user', 400) } - if (description !== undefined) { - activationUpdateData.description = description + + const activateResult = await performActivateVersion({ + workflowId: id, + version: versionNum, + userId: actorUserId, + workflow: workflowData as Record, + requestId, + request, + }) + + if (!activateResult.success) { + const status = + activateResult.errorCode === 'not_found' + ? 404 + : activateResult.errorCode === 'validation' + ? 400 + : 500 + return createErrorResponse( + activateResult.error || 'Failed to activate deployment', + status + ) } - const [updated] = await db - .update(workflowDeploymentVersion) - .set(activationUpdateData) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) + let updatedName: string | null | undefined + let updatedDescription: string | null | undefined + if (name !== undefined || description !== undefined) { + const activationUpdateData: { name?: string; description?: string | null } = {} + if (name !== undefined) { + activationUpdateData.name = name + } + if (description !== undefined) { + activationUpdateData.description = description + } + + const [updated] = await db + .update(workflowDeploymentVersion) + .set(activationUpdateData) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.version, versionNum) + ) ) - ) - .returning({ - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - }) - - if (updated) { - updatedName = updated.name - updatedDescription = updated.description - logger.info( - `[${requestId}] Updated deployment version ${version} metadata during activation`, - { name: activationUpdateData.name, description: activationUpdateData.description } - ) + .returning({ + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + }) + + if (updated) { + updatedName = updated.name + updatedDescription = updated.description + logger.info( + `[${requestId}] Updated deployment version ${version} metadata during activation`, + { name: activationUpdateData.name, description: activationUpdateData.description } + ) + } } - } - const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId - captureServerEvent( - actorUserId, - 'deployment_version_activated', - { workflow_id: id, workspace_id: wsId ?? '', version: versionNum }, - wsId ? { groups: { workspace: wsId } } : undefined - ) + const wsId = (workflowData as { workspaceId?: string } | null)?.workspaceId + captureServerEvent( + actorUserId, + 'deployment_version_activated', + { workflow_id: id, workspace_id: wsId ?? '', version: versionNum }, + wsId ? { groups: { workspace: wsId } } : undefined + ) - return createSuccessResponse({ - success: true, - deployedAt: activateResult.deployedAt, - warnings: activateResult.warnings, - ...(updatedName !== undefined && { name: updatedName }), - ...(updatedDescription !== undefined && { description: updatedDescription }), - }) - } + return createSuccessResponse({ + success: true, + deployedAt: activateResult.deployedAt, + warnings: activateResult.warnings, + ...(updatedName !== undefined && { name: updatedName }), + ...(updatedDescription !== undefined && { description: updatedDescription }), + }) + } - // Handle name/description updates - const updateData: { name?: string; description?: string | null } = {} - if (name !== undefined) { - updateData.name = name - } - if (description !== undefined) { - updateData.description = description - } + // Handle name/description updates + const updateData: { name?: string; description?: string | null } = {} + if (name !== undefined) { + updateData.name = name + } + if (description !== undefined) { + updateData.description = description + } - const [updated] = await db - .update(workflowDeploymentVersion) - .set(updateData) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.version, versionNum) + const [updated] = await db + .update(workflowDeploymentVersion) + .set(updateData) + .where( + and( + eq(workflowDeploymentVersion.workflowId, id), + eq(workflowDeploymentVersion.version, versionNum) + ) ) - ) - .returning({ - id: workflowDeploymentVersion.id, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, + .returning({ + id: workflowDeploymentVersion.id, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + }) + + if (!updated) { + return createErrorResponse('Deployment version not found', 404) + } + + logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, { + name: updateData.name, + description: updateData.description, }) - if (!updated) { - return createErrorResponse('Deployment version not found', 404) + return createSuccessResponse({ name: updated.name, description: updated.description }) + } catch (error: any) { + logger.error( + `[${requestId}] Error updating deployment version ${version} for workflow ${id}`, + error + ) + return createErrorResponse(error.message || 'Failed to update deployment version', 500) } - - logger.info(`[${requestId}] Updated deployment version ${version} for workflow ${id}`, { - name: updateData.name, - description: updateData.description, - }) - - return createSuccessResponse({ name: updated.name, description: updated.description }) - } catch (error: any) { - logger.error( - `[${requestId}] Error updating deployment version ${version} for workflow ${id}`, - error - ) - return createErrorResponse(error.message || 'Failed to update deployment version', 500) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/deployments/route.ts b/apps/sim/app/api/workflows/[id]/deployments/route.ts index ac2e7e1015f..1bc72ae66b1 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -11,40 +12,42 @@ const logger = createLogger('WorkflowDeploymentsListAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params - try { - const { error } = await validateWorkflowPermissions(id, requestId, 'read') - if (error) { - return createErrorResponse(error.message, error.status) - } + try { + const { error } = await validateWorkflowPermissions(id, requestId, 'read') + if (error) { + return createErrorResponse(error.message, error.status) + } - const rawVersions = await db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - createdBy: workflowDeploymentVersion.createdBy, - deployedBy: user.name, - }) - .from(workflowDeploymentVersion) - .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) - .where(eq(workflowDeploymentVersion.workflowId, id)) - .orderBy(desc(workflowDeploymentVersion.version)) + const rawVersions = await db + .select({ + id: workflowDeploymentVersion.id, + version: workflowDeploymentVersion.version, + name: workflowDeploymentVersion.name, + description: workflowDeploymentVersion.description, + isActive: workflowDeploymentVersion.isActive, + createdAt: workflowDeploymentVersion.createdAt, + createdBy: workflowDeploymentVersion.createdBy, + deployedBy: user.name, + }) + .from(workflowDeploymentVersion) + .leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id)) + .where(eq(workflowDeploymentVersion.workflowId, id)) + .orderBy(desc(workflowDeploymentVersion.version)) - const versions = rawVersions.map((v) => ({ - ...v, - deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null), - })) + const versions = rawVersions.map((v) => ({ + ...v, + deployedBy: v.deployedBy ?? (v.createdBy === 'admin-api' ? 'Admin' : null), + })) - return createSuccessResponse({ versions }) - } catch (error: any) { - logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error) - return createErrorResponse(error.message || 'Failed to list deployments', 500) + return createSuccessResponse({ versions }) + } catch (error: any) { + logger.error(`[${requestId}] Error listing deployments for workflow: ${id}`, error) + return createErrorResponse(error.message || 'Failed to list deployments', 500) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 63c230f686b..500604d0d79 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -5,6 +5,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' @@ -20,106 +21,110 @@ const DuplicateRequestSchema = z.object({ }) // POST /api/workflows/[id]/duplicate - Duplicate a workflow with all its blocks, edges, and subflows -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: sourceWorkflowId } = await params - const requestId = generateRequestId() - const startTime = Date.now() +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: sourceWorkflowId } = await params + const requestId = generateRequestId() + const startTime = Date.now() - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - try { - const body = await req.json() - const { name, description, color, workspaceId, folderId, newId } = - DuplicateRequestSchema.parse(body) + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn( + `[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) + try { + const body = await req.json() + const { name, description, color, workspaceId, folderId, newId } = + DuplicateRequestSchema.parse(body) - const result = await duplicateWorkflow({ - sourceWorkflowId, - userId, - name, - description, - color, - workspaceId, - folderId, - requestId, - newWorkflowId: newId, - }) + logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`) - try { - PlatformEvents.workflowDuplicated({ + const result = await duplicateWorkflow({ sourceWorkflowId, - newWorkflowId: result.id, + userId, + name, + description, + color, workspaceId, + folderId, + requestId, + newWorkflowId: newId, }) - } catch { - // Telemetry should not fail the operation - } - captureServerEvent( - userId, - 'workflow_duplicated', - { - source_workflow_id: sourceWorkflowId, - new_workflow_id: result.id, - workspace_id: workspaceId ?? '', - }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined - ) + try { + PlatformEvents.workflowDuplicated({ + sourceWorkflowId, + newWorkflowId: result.id, + workspaceId, + }) + } catch { + // Telemetry should not fail the operation + } - const elapsed = Date.now() - startTime - logger.info( - `[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms` - ) + captureServerEvent( + userId, + 'workflow_duplicated', + { + source_workflow_id: sourceWorkflowId, + new_workflow_id: result.id, + workspace_id: workspaceId ?? '', + }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + + const elapsed = Date.now() - startTime + logger.info( + `[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms` + ) - recordAudit({ - workspaceId: workspaceId || null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_DUPLICATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: result.id, - resourceName: result.name, - description: `Duplicated workflow from ${sourceWorkflowId}`, - metadata: { sourceWorkflowId }, - request: req, - }) + recordAudit({ + workspaceId: workspaceId || null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.WORKFLOW_DUPLICATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.id, + resourceName: result.name, + description: `Duplicated workflow from ${sourceWorkflowId}`, + metadata: { sourceWorkflowId }, + request: req, + }) - return NextResponse.json(result, { status: 201 }) - } catch (error) { - if (error instanceof Error) { - if (error.message === 'Source workflow not found') { - logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) - return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 }) + return NextResponse.json(result, { status: 201 }) + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Source workflow not found') { + logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) + return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 }) + } + + if (error.message === 'Source workflow not found or access denied') { + logger.warn( + `[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } } - if (error.message === 'Source workflow not found or access denied') { - logger.warn( - `[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}` + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } + const elapsed = Date.now() - startTime + logger.error( + `[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`, + error ) + return NextResponse.json({ error: 'Failed to duplicate workflow' }, { status: 500 }) } - - const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error duplicating workflow ${sourceWorkflowId} after ${elapsed}ms:`, - error - ) - return NextResponse.json({ error: 'Failed to duplicate workflow' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 86a4a722eb8..b4f0942d50a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -14,6 +14,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId, isValidUuid } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { DispatchQueueFullError, enqueueWorkspaceDispatch, @@ -332,23 +333,25 @@ async function enqueueDirectWorkflowExecution( * Unified server-side workflow execution endpoint. * Supports both SSE streaming (for interactive/manual runs) and direct JSON responses (for background jobs). */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers) - if (isSessionRequest) { - return handleExecutePost(req, params) - } +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const isSessionRequest = req.headers.has('cookie') && !hasExternalApiCredentials(req.headers) + if (isSessionRequest) { + return handleExecutePost(req, params) + } - const ticket = tryAdmit() - if (!ticket) { - return admissionRejectedResponse() - } + const ticket = tryAdmit() + if (!ticket) { + return admissionRejectedResponse() + } - try { - return await handleExecutePost(req, params) - } finally { - ticket.release() + try { + return await handleExecutePost(req, params) + } finally { + ticket.release() + } } -} +) async function handleExecutePost( req: NextRequest, diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts index 4fb045c5816..7f023f9582f 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { markExecutionCancelled } from '@/lib/execution/cancellation' import { abortManualExecution } from '@/lib/execution/manual-cancellation' import { captureServerEvent } from '@/lib/posthog/server' @@ -11,79 +12,81 @@ const logger = createLogger('CancelExecutionAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string; executionId: string }> } -) { - const { id: workflowId, executionId } = await params +export const POST = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; executionId: string }> } + ) => { + const { id: workflowId, executionId } = await params - try { - const auth = await checkHybridAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } + try { + const auth = await checkHybridAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } - const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: auth.userId, - action: 'write', - }) - if (!workflowAuthorization.allowed) { - return NextResponse.json( - { error: workflowAuthorization.message || 'Access denied' }, - { status: workflowAuthorization.status } - ) - } + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action: 'write', + }) + if (!workflowAuthorization.allowed) { + return NextResponse.json( + { error: workflowAuthorization.message || 'Access denied' }, + { status: workflowAuthorization.status } + ) + } - if ( - auth.apiKeyType === 'workspace' && - workflowAuthorization.workflow?.workspaceId !== auth.workspaceId - ) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) - } + if ( + auth.apiKeyType === 'workspace' && + workflowAuthorization.workflow?.workspaceId !== auth.workspaceId + ) { + return NextResponse.json( + { error: 'API key is not authorized for this workspace' }, + { status: 403 } + ) + } + + logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId }) - logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId }) + const cancellation = await markExecutionCancelled(executionId) + const locallyAborted = abortManualExecution(executionId) - const cancellation = await markExecutionCancelled(executionId) - const locallyAborted = abortManualExecution(executionId) + if (cancellation.durablyRecorded) { + logger.info('Execution marked as cancelled in Redis', { executionId }) + } else if (locallyAborted) { + logger.info('Execution cancelled via local in-process fallback', { executionId }) + } else { + logger.warn('Execution cancellation was not durably recorded', { + executionId, + reason: cancellation.reason, + }) + } - if (cancellation.durablyRecorded) { - logger.info('Execution marked as cancelled in Redis', { executionId }) - } else if (locallyAborted) { - logger.info('Execution cancelled via local in-process fallback', { executionId }) - } else { - logger.warn('Execution cancellation was not durably recorded', { + if (cancellation.durablyRecorded || locallyAborted) { + const workspaceId = workflowAuthorization.workflow?.workspaceId + captureServerEvent( + auth.userId, + 'workflow_execution_cancelled', + { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } + + return NextResponse.json({ + success: cancellation.durablyRecorded || locallyAborted, executionId, + redisAvailable: cancellation.reason !== 'redis_unavailable', + durablyRecorded: cancellation.durablyRecorded, + locallyAborted, reason: cancellation.reason, }) - } - - if (cancellation.durablyRecorded || locallyAborted) { - const workspaceId = workflowAuthorization.workflow?.workspaceId - captureServerEvent( - auth.userId, - 'workflow_execution_cancelled', - { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, - workspaceId ? { groups: { workspace: workspaceId } } : undefined + } catch (error: any) { + logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message }) + return NextResponse.json( + { error: error.message || 'Failed to cancel execution' }, + { status: 500 } ) } - - return NextResponse.json({ - success: cancellation.durablyRecorded || locallyAborted, - executionId, - redisAvailable: cancellation.reason !== 'redis_unavailable', - durablyRecorded: cancellation.durablyRecorded, - locallyAborted, - reason: cancellation.reason, - }) - } catch (error: any) { - logger.error('Failed to cancel execution', { workflowId, executionId, error: error.message }) - return NextResponse.json( - { error: error.message || 'Failed to cancel execution' }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts index ad2f94722d1..46fb800286f 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { SSE_HEADERS } from '@/lib/core/utils/sse' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { type ExecutionStreamStatus, getExecutionMeta, @@ -22,152 +23,157 @@ function isTerminalStatus(status: ExecutionStreamStatus): boolean { export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ id: string; executionId: string }> } -) { - const { id: workflowId, executionId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: session.user.id, - action: 'read', - }) - if (!workflowAuthorization.allowed) { - return NextResponse.json( - { error: workflowAuthorization.message || 'Access denied' }, - { status: workflowAuthorization.status } - ) - } - - const meta = await getExecutionMeta(executionId) - if (!meta) { - return NextResponse.json({ error: 'Execution buffer not found or expired' }, { status: 404 }) - } - - if (meta.workflowId && meta.workflowId !== workflowId) { - return NextResponse.json( - { error: 'Execution does not belong to this workflow' }, - { status: 403 } - ) - } - - const fromParam = req.nextUrl.searchParams.get('from') - const parsed = fromParam ? Number.parseInt(fromParam, 10) : 0 - const fromEventId = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 - - logger.info('Reconnection stream requested', { - workflowId, - executionId, - fromEventId, - metaStatus: meta.status, - }) - - const encoder = new TextEncoder() - - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - let lastEventId = fromEventId - const pollDeadline = Date.now() + MAX_POLL_DURATION_MS - - const enqueue = (text: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(text)) - } catch { - closed = true - } - } - - try { - const events = await readExecutionEvents(executionId, lastEventId) - for (const entry of events) { +export const GET = withRouteHandler( + async ( + req: NextRequest, + { params }: { params: Promise<{ id: string; executionId: string }> } + ) => { + const { id: workflowId, executionId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: session.user.id, + action: 'read', + }) + if (!workflowAuthorization.allowed) { + return NextResponse.json( + { error: workflowAuthorization.message || 'Access denied' }, + { status: workflowAuthorization.status } + ) + } + + const meta = await getExecutionMeta(executionId) + if (!meta) { + return NextResponse.json( + { error: 'Execution buffer not found or expired' }, + { status: 404 } + ) + } + + if (meta.workflowId && meta.workflowId !== workflowId) { + return NextResponse.json( + { error: 'Execution does not belong to this workflow' }, + { status: 403 } + ) + } + + const fromParam = req.nextUrl.searchParams.get('from') + const parsed = fromParam ? Number.parseInt(fromParam, 10) : 0 + const fromEventId = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 + + logger.info('Reconnection stream requested', { + workflowId, + executionId, + fromEventId, + metaStatus: meta.status, + }) + + const encoder = new TextEncoder() + + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + let lastEventId = fromEventId + const pollDeadline = Date.now() + MAX_POLL_DURATION_MS + + const enqueue = (text: string) => { if (closed) return - entry.event.eventId = entry.eventId - enqueue(formatSSEEvent(entry.event)) - lastEventId = entry.eventId - } - - const currentMeta = await getExecutionMeta(executionId) - if (!currentMeta || isTerminalStatus(currentMeta.status)) { - enqueue('data: [DONE]\n\n') - if (!closed) controller.close() - return + try { + controller.enqueue(encoder.encode(text)) + } catch { + closed = true + } } - while (!closed && Date.now() < pollDeadline) { - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) - if (closed) return - - const newEvents = await readExecutionEvents(executionId, lastEventId) - for (const entry of newEvents) { + try { + const events = await readExecutionEvents(executionId, lastEventId) + for (const entry of events) { if (closed) return entry.event.eventId = entry.eventId enqueue(formatSSEEvent(entry.event)) lastEventId = entry.eventId } - const polledMeta = await getExecutionMeta(executionId) - if (!polledMeta || isTerminalStatus(polledMeta.status)) { - const finalEvents = await readExecutionEvents(executionId, lastEventId) - for (const entry of finalEvents) { + const currentMeta = await getExecutionMeta(executionId) + if (!currentMeta || isTerminalStatus(currentMeta.status)) { + enqueue('data: [DONE]\n\n') + if (!closed) controller.close() + return + } + + while (!closed && Date.now() < pollDeadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) + if (closed) return + + const newEvents = await readExecutionEvents(executionId, lastEventId) + for (const entry of newEvents) { if (closed) return entry.event.eventId = entry.eventId enqueue(formatSSEEvent(entry.event)) lastEventId = entry.eventId } - enqueue('data: [DONE]\n\n') - if (!closed) controller.close() - return + + const polledMeta = await getExecutionMeta(executionId) + if (!polledMeta || isTerminalStatus(polledMeta.status)) { + const finalEvents = await readExecutionEvents(executionId, lastEventId) + for (const entry of finalEvents) { + if (closed) return + entry.event.eventId = entry.eventId + enqueue(formatSSEEvent(entry.event)) + lastEventId = entry.eventId + } + enqueue('data: [DONE]\n\n') + if (!closed) controller.close() + return + } } - } - if (!closed) { - logger.warn('Reconnection stream poll deadline reached', { executionId }) - enqueue('data: [DONE]\n\n') - controller.close() - } - } catch (error) { - logger.error('Error in reconnection stream', { - executionId, - error: error instanceof Error ? error.message : String(error), - }) - if (!closed) { - try { + if (!closed) { + logger.warn('Reconnection stream poll deadline reached', { executionId }) + enqueue('data: [DONE]\n\n') controller.close() - } catch {} + } + } catch (error) { + logger.error('Error in reconnection stream', { + executionId, + error: error instanceof Error ? error.message : String(error), + }) + if (!closed) { + try { + controller.close() + } catch {} + } } - } - }, - cancel() { - closed = true - logger.info('Client disconnected from reconnection stream', { executionId }) - }, - }) - - return new NextResponse(stream, { - headers: { - ...SSE_HEADERS, - 'X-Execution-Id': executionId, - }, - }) - } catch (error: any) { - logger.error('Failed to start reconnection stream', { - workflowId, - executionId, - error: error.message, - }) - return NextResponse.json( - { error: error.message || 'Failed to start reconnection stream' }, - { status: 500 } - ) + }, + cancel() { + closed = true + logger.info('Client disconnected from reconnection stream', { executionId }) + }, + }) + + return new NextResponse(stream, { + headers: { + ...SSE_HEADERS, + 'X-Execution-Id': executionId, + }, + }) + } catch (error: any) { + logger.error('Failed to start reconnection stream', { + workflowId, + executionId, + error: error.message, + }) + return NextResponse.json( + { error: error.message || 'Failed to start reconnection stream' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.ts b/apps/sim/app/api/workflows/[id]/form/status/route.ts index 6f22afafd6c..00ce06ce3a6 100644 --- a/apps/sim/app/api/workflows/[id]/form/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/form/status/route.ts @@ -4,55 +4,58 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('FormStatusAPI') -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return createErrorResponse('Unauthorized', 401) - } - - const { id: workflowId } = await params - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: auth.userId, - action: 'read', - }) - if (!authorization.allowed) { - return createErrorResponse( - authorization.message || 'Access denied', - authorization.status || 403 - ) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return createErrorResponse('Unauthorized', 401) + } - const formResult = await db - .select({ - id: form.id, - identifier: form.identifier, - title: form.title, - isActive: form.isActive, + const { id: workflowId } = await params + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action: 'read', }) - .from(form) - .where(and(eq(form.workflowId, workflowId), eq(form.isActive, true))) - .limit(1) + if (!authorization.allowed) { + return createErrorResponse( + authorization.message || 'Access denied', + authorization.status || 403 + ) + } + + const formResult = await db + .select({ + id: form.id, + identifier: form.identifier, + title: form.title, + isActive: form.isActive, + }) + .from(form) + .where(and(eq(form.workflowId, workflowId), eq(form.isActive, true))) + .limit(1) + + if (formResult.length === 0) { + return createSuccessResponse({ + isDeployed: false, + form: null, + }) + } - if (formResult.length === 0) { return createSuccessResponse({ - isDeployed: false, - form: null, + isDeployed: true, + form: formResult[0], }) + } catch (error: any) { + logger.error('Error fetching form status:', error) + return createErrorResponse(error.message || 'Failed to fetch form status', 500) } - - return createSuccessResponse({ - isDeployed: true, - form: formResult[0], - }) - } catch (error: any) { - logger.error('Error fetching form status:', error) - return createErrorResponse(error.message || 'Failed to fetch form status', 500) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/log/route.ts b/apps/sim/app/api/workflows/[id]/log/route.ts index dc50fa6bd4f..91d3b69f3aa 100644 --- a/apps/sim/app/api/workflows/[id]/log/route.ts +++ b/apps/sim/app/api/workflows/[id]/log/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { z } from 'zod' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' @@ -31,103 +32,110 @@ const postBodySchema = z.object({ export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const accessValidation = await validateWorkflowAccess(request, id, false) - if (accessValidation.error) { - logger.warn( - `[${requestId}] Workflow access validation failed: ${accessValidation.error.message}` - ) - return createErrorResponse(accessValidation.error.message, accessValidation.error.status) - } - - const body = await request.json() - const validation = postBodySchema.safeParse(body) - - if (!validation.success) { - logger.warn(`[${requestId}] Invalid request body: ${validation.error.message}`) - return createErrorResponse(validation.error.errors[0]?.message || 'Invalid request body', 400) - } - - const { logs, executionId, result } = validation.data - - if (result) { - if (!executionId) { - logger.warn(`[${requestId}] Missing executionId for result logging`) - return createErrorResponse('executionId is required when logging results', 400) +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id } = await params + + try { + const accessValidation = await validateWorkflowAccess(request, id, false) + if (accessValidation.error) { + logger.warn( + `[${requestId}] Workflow access validation failed: ${accessValidation.error.message}` + ) + return createErrorResponse(accessValidation.error.message, accessValidation.error.status) } - logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, { - executionId, - success: result.success, - }) - - const isChatExecution = result.metadata?.source === 'chat' + const body = await request.json() + const validation = postBodySchema.safeParse(body) - const triggerType = isChatExecution ? 'chat' : 'manual' - const loggingSession = new LoggingSession(id, executionId, triggerType, requestId) - - const workspaceId = accessValidation.workflow.workspaceId - if (!workspaceId) { - logger.error(`[${requestId}] Workflow ${id} has no workspaceId`) - return createErrorResponse('Workflow has no associated workspace', 500) - } - const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) - if (!billedAccountUserId) { - logger.error(`[${requestId}] Unable to resolve billed account for workspace ${workspaceId}`) - return createErrorResponse('Unable to resolve billing account for this workspace', 500) + if (!validation.success) { + logger.warn(`[${requestId}] Invalid request body: ${validation.error.message}`) + return createErrorResponse( + validation.error.errors[0]?.message || 'Invalid request body', + 400 + ) } - await loggingSession.safeStart({ - userId: billedAccountUserId, - workspaceId, - variables: {}, - }) + const { logs, executionId, result } = validation.data - const resultWithOutput = { - ...result, - output: result.output ?? {}, - } + if (result) { + if (!executionId) { + logger.warn(`[${requestId}] Missing executionId for result logging`) + return createErrorResponse('executionId is required when logging results', 400) + } - const { traceSpans, totalDuration } = buildTraceSpans(resultWithOutput as ExecutionResult) + logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, { + executionId, + success: result.success, + }) - if (result.success === false) { - const message = result.error || 'Workflow execution failed' - await loggingSession.safeCompleteWithError({ - endedAt: new Date().toISOString(), - totalDurationMs: totalDuration || result.metadata?.duration || 0, - error: { message }, - traceSpans, + const isChatExecution = result.metadata?.source === 'chat' + + const triggerType = isChatExecution ? 'chat' : 'manual' + const loggingSession = new LoggingSession(id, executionId, triggerType, requestId) + + const workspaceId = accessValidation.workflow.workspaceId + if (!workspaceId) { + logger.error(`[${requestId}] Workflow ${id} has no workspaceId`) + return createErrorResponse('Workflow has no associated workspace', 500) + } + const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) + if (!billedAccountUserId) { + logger.error( + `[${requestId}] Unable to resolve billed account for workspace ${workspaceId}` + ) + return createErrorResponse('Unable to resolve billing account for this workspace', 500) + } + + await loggingSession.safeStart({ + userId: billedAccountUserId, + workspaceId, + variables: {}, }) - } else { - await loggingSession.safeComplete({ - endedAt: new Date().toISOString(), - totalDurationMs: totalDuration || result.metadata?.duration || 0, - finalOutput: result.output || {}, - traceSpans, + + const resultWithOutput = { + ...result, + output: result.output ?? {}, + } + + const { traceSpans, totalDuration } = buildTraceSpans(resultWithOutput as ExecutionResult) + + if (result.success === false) { + const message = result.error || 'Workflow execution failed' + await loggingSession.safeCompleteWithError({ + endedAt: new Date().toISOString(), + totalDurationMs: totalDuration || result.metadata?.duration || 0, + error: { message }, + traceSpans, + }) + } else { + await loggingSession.safeComplete({ + endedAt: new Date().toISOString(), + totalDurationMs: totalDuration || result.metadata?.duration || 0, + finalOutput: result.output || {}, + traceSpans, + }) + } + + return createSuccessResponse({ + message: 'Execution logs persisted successfully', }) } - return createSuccessResponse({ - message: 'Execution logs persisted successfully', + if (!logs || !Array.isArray(logs) || logs.length === 0) { + logger.warn(`[${requestId}] No logs provided for workflow: ${id}`) + return createErrorResponse('No logs provided', 400) + } + + logger.info(`[${requestId}] Persisting ${logs.length} logs for workflow: ${id}`, { + executionId, }) - } - if (!logs || !Array.isArray(logs) || logs.length === 0) { - logger.warn(`[${requestId}] No logs provided for workflow: ${id}`) - return createErrorResponse('No logs provided', 400) + return createSuccessResponse({ message: 'Logs persisted successfully' }) + } catch (error: any) { + logger.error(`[${requestId}] Error persisting logs for workflow: ${id}`, error) + return createErrorResponse(error.message || 'Failed to persist logs', 500) } - - logger.info(`[${requestId}] Persisting ${logs.length} logs for workflow: ${id}`, { - executionId, - }) - - return createSuccessResponse({ message: 'Logs persisted successfully' }) - } catch (error: any) { - logger.error(`[${requestId}] Error persisting logs for workflow: ${id}`, error) - return createErrorResponse(error.message || 'Failed to persist logs', 500) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts index 618d4f69c33..a049fa1101e 100644 --- a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts @@ -1,33 +1,36 @@ import { type NextRequest, NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function GET( - request: NextRequest, - { - params, - }: { - params: Promise<{ id: string; executionId: string }> - } -) { - const { id: workflowId, executionId } = await params +export const GET = withRouteHandler( + async ( + request: NextRequest, + { + params, + }: { + params: Promise<{ id: string; executionId: string }> + } + ) => { + const { id: workflowId, executionId } = await params - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } + const access = await validateWorkflowAccess(request, workflowId, false) + if (access.error) { + return NextResponse.json({ error: access.error.message }, { status: access.error.status }) + } - const detail = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) + const detail = await PauseResumeManager.getPausedExecutionDetail({ + workflowId, + executionId, + }) - if (!detail) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } + if (!detail) { + return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) + } - return NextResponse.json(detail) -} + return NextResponse.json(detail) + } +) diff --git a/apps/sim/app/api/workflows/[id]/paused/route.ts b/apps/sim/app/api/workflows/[id]/paused/route.ts index 62639e1fb5f..740fda7686b 100644 --- a/apps/sim/app/api/workflows/[id]/paused/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' @@ -10,38 +11,40 @@ const queryParamsSchema = z.object({ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export async function GET( - request: NextRequest, - { - params, - }: { - params: Promise<{ id: string }> +export const GET = withRouteHandler( + async ( + request: NextRequest, + { + params, + }: { + params: Promise<{ id: string }> + } + ) => { + const { id: workflowId } = await params + + const access = await validateWorkflowAccess(request, workflowId, false) + if (access.error) { + return NextResponse.json({ error: access.error.message }, { status: access.error.status }) + } + + const validation = queryParamsSchema.safeParse({ + status: request.nextUrl.searchParams.get('status'), + }) + + if (!validation.success) { + return NextResponse.json( + { error: validation.error.errors[0]?.message || 'Invalid query parameters' }, + { status: 400 } + ) + } + + const { status: statusFilter } = validation.data + + const pausedExecutions = await PauseResumeManager.listPausedExecutions({ + workflowId, + status: statusFilter, + }) + + return NextResponse.json({ pausedExecutions }) } -) { - const { id: workflowId } = await params - - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } - - const validation = queryParamsSchema.safeParse({ - status: request.nextUrl.searchParams.get('status'), - }) - - if (!validation.success) { - return NextResponse.json( - { error: validation.error.errors[0]?.message || 'Invalid query parameters' }, - { status: 400 } - ) - } - - const { status: statusFilter } = validation.data - - const pausedExecutions = await PauseResumeManager.listPausedExecutions({ - workflowId, - status: statusFilter, - }) - - return NextResponse.json({ pausedExecutions }) -} +) diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index a9d6b6ba1a5..87fc20d5129 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { restoreWorkflow } from '@/lib/workflows/lifecycle' import { getWorkflowById } from '@/lib/workflows/utils' @@ -10,68 +11,70 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkflowAPI') -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id: workflowId } = await params +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: workflowId } = await params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const workflowData = await getWorkflowById(workflowId, { includeArchived: true }) - if (!workflowData) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const workflowData = await getWorkflowById(workflowId, { includeArchived: true }) + if (!workflowData) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } - if (workflowData.workspaceId) { - const permission = await getUserEntityPermissions( - auth.userId, - 'workspace', - workflowData.workspaceId - ) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + if (workflowData.workspaceId) { + const permission = await getUserEntityPermissions( + auth.userId, + 'workspace', + workflowData.workspaceId + ) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + } else if (workflowData.userId !== auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } else if (workflowData.userId !== auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const result = await restoreWorkflow(workflowId, { requestId }) + const result = await restoreWorkflow(workflowId, { requestId }) - if (!result.restored) { - return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 }) - } + if (!result.restored) { + return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 }) + } - logger.info(`[${requestId}] Restored workflow ${workflowId}`) + logger.info(`[${requestId}] Restored workflow ${workflowId}`) - recordAudit({ - workspaceId: workflowData.workspaceId, - actorId: auth.userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_RESTORED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name, - description: `Restored workflow "${workflowData.name}"`, - request, - }) + recordAudit({ + workspaceId: workflowData.workspaceId, + actorId: auth.userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.WORKFLOW_RESTORED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowData.name, + description: `Restored workflow "${workflowData.name}"`, + request, + }) - captureServerEvent( - auth.userId, - 'workflow_restored', - { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, - workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined - ) + captureServerEvent( + auth.userId, + 'workflow_restored', + { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, + workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + ) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 3d74fe527fa..c9b68ca180c 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -26,73 +27,99 @@ const UpdateWorkflowSchema = z.object({ * Fetch a single workflow by ID * Uses hybrid approach: try normalized tables first, fallback to JSON blob */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) - if (!auth.success) { - logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const isInternalCall = auth.authType === AuthType.INTERNAL_JWT - const userId = auth.userId || null - - let workflowData = await getWorkflowById(workflowId) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const startTime = Date.now() + const { id: workflowId } = await params + + try { + const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + if (!auth.success) { + logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const isInternalCall = auth.authType === AuthType.INTERNAL_JWT + const userId = auth.userId || null - if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) { - return NextResponse.json( - { error: 'API key is not authorized for this workspace' }, - { status: 403 } - ) - } + let workflowData = await getWorkflowById(workflowId) - if (isInternalCall && !userId) { - // Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId. - // These are already authenticated via internal JWT; allow read access. - logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`) - } else if (!userId) { - logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } else { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - if (!authorization.workflow) { + if (!workflowData) { logger.warn(`[${requestId}] Workflow ${workflowId} not found`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - workflowData = authorization.workflow - if (!authorization.allowed) { - logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`) + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) { return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status } + { error: 'API key is not authorized for this workspace' }, + { status: 403 } ) } - } - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (isInternalCall && !userId) { + // Internal system calls (e.g. workflow-in-workflow executor) may not carry a userId. + // These are already authenticated via internal JWT; allow read access. + logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`) + } else if (!userId) { + logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } else { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + if (!authorization.workflow) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + + workflowData = authorization.workflow + if (!authorization.allowed) { + logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`) + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status } + ) + } + } + + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + + if (normalizedData) { + const finalWorkflowData = { + ...workflowData, + state: { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + lastSaved: Date.now(), + isDeployed: workflowData.isDeployed || false, + deployedAt: workflowData.deployedAt, + metadata: { + name: workflowData.name, + description: workflowData.description, + }, + }, + variables: workflowData.variables || {}, + } - if (normalizedData) { - const finalWorkflowData = { + logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`) + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) + + return NextResponse.json({ data: finalWorkflowData }, { status: 200 }) + } + + const emptyWorkflowData = { ...workflowData, state: { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, + blocks: {}, + edges: [], + loops: {}, + parallels: {}, lastSaved: Date.now(), isDeployed: workflowData.isDeployed || false, deployedAt: workflowData.deployedAt, @@ -104,263 +131,240 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ variables: workflowData.variables || {}, } - logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`) + return NextResponse.json({ data: emptyWorkflowData }, { status: 200 }) + } catch (error: any) { const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) - - return NextResponse.json({ data: finalWorkflowData }, { status: 200 }) + logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - const emptyWorkflowData = { - ...workflowData, - state: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: Date.now(), - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt, - metadata: { - name: workflowData.name, - description: workflowData.description, - }, - }, - variables: workflowData.variables || {}, - } - - return NextResponse.json({ data: emptyWorkflowData }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) /** * DELETE /api/workflows/[id] * Delete a workflow by ID */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const startTime = Date.now() + const { id: workflowId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = auth.userId + const userId = auth.userId - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'admin', - }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'admin', + }) + const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + if (!workflowData) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } - const canDelete = authorization.allowed + const canDelete = authorization.allowed - if (!canDelete) { - logger.warn( - `[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } + if (!canDelete) { + logger.warn( + `[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}` + ) + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status || 403 } + ) + } - const { searchParams } = new URL(request.url) - const checkTemplates = searchParams.get('check-templates') === 'true' - const deleteTemplatesParam = searchParams.get('deleteTemplates') - - if (checkTemplates) { - const { templates } = await import('@sim/db/schema') - const publishedTemplates = await db - .select({ - id: templates.id, - name: templates.name, - views: templates.views, - stars: templates.stars, - status: templates.status, + const { searchParams } = new URL(request.url) + const checkTemplates = searchParams.get('check-templates') === 'true' + const deleteTemplatesParam = searchParams.get('deleteTemplates') + + if (checkTemplates) { + const { templates } = await import('@sim/db/schema') + const publishedTemplates = await db + .select({ + id: templates.id, + name: templates.name, + views: templates.views, + stars: templates.stars, + status: templates.status, + }) + .from(templates) + .where(eq(templates.workflowId, workflowId)) + + return NextResponse.json({ + hasPublishedTemplates: publishedTemplates.length > 0, + count: publishedTemplates.length, + publishedTemplates: publishedTemplates.map((t) => ({ + id: t.id, + name: t.name, + views: t.views, + stars: t.stars, + })), }) - .from(templates) - .where(eq(templates.workflowId, workflowId)) - - return NextResponse.json({ - hasPublishedTemplates: publishedTemplates.length > 0, - count: publishedTemplates.length, - publishedTemplates: publishedTemplates.map((t) => ({ - id: t.id, - name: t.name, - views: t.views, - stars: t.stars, - })), + } + + const result = await performDeleteWorkflow({ + workflowId, + userId, + requestId, + templateAction: deleteTemplatesParam === 'delete' ? 'delete' : 'orphan', }) - } - const result = await performDeleteWorkflow({ - workflowId, - userId, - requestId, - templateAction: deleteTemplatesParam === 'delete' ? 'delete' : 'orphan', - }) - - if (!result.success) { - const status = - result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: result.error }, { status }) - } + if (!result.success) { + const status = + result.errorCode === 'not_found' ? 404 : result.errorCode === 'validation' ? 400 : 500 + return NextResponse.json({ error: result.error }, { status }) + } + + captureServerEvent( + userId, + 'workflow_deleted', + { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, + workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined + ) + + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) - captureServerEvent( - userId, - 'workflow_deleted', - { workflow_id: workflowId, workspace_id: workflowData.workspaceId ?? '' }, - workflowData.workspaceId ? { groups: { workspace: workflowData.workspaceId } } : undefined - ) - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error: any) { + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * PUT /api/workflows/[id] * Update workflow metadata (name, description, color, folderId) */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = auth.userId +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const startTime = Date.now() + const { id: workflowId } = await params + + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const body = await request.json() - const updates = UpdateWorkflowSchema.parse(body) + const userId = auth.userId - // Fetch the workflow to check ownership/access - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) + const body = await request.json() + const updates = UpdateWorkflowSchema.parse(body) - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + // Fetch the workflow to check ownership/access + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + const workflowData = authorization.workflow || (await getWorkflowById(workflowId)) - const canUpdate = authorization.allowed + if (!workflowData) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) - } + const canUpdate = authorization.allowed - const updateData: Record = { updatedAt: new Date() } - if (updates.name !== undefined) updateData.name = updates.name - if (updates.description !== undefined) updateData.description = updates.description - if (updates.color !== undefined) updateData.color = updates.color - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder - - if (updates.name !== undefined || updates.folderId !== undefined) { - const targetName = updates.name ?? workflowData.name - const targetFolderId = - updates.folderId !== undefined ? updates.folderId : workflowData.folderId - - if (!workflowData.workspaceId) { - logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + if (!canUpdate) { + logger.warn( + `[${requestId}] User ${userId} denied permission to update workflow ${workflowId}` + ) + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status || 403 } + ) } - const conditions = [ - eq(workflow.workspaceId, workflowData.workspaceId), - isNull(workflow.archivedAt), - eq(workflow.name, targetName), - ne(workflow.id, workflowId), - ] - - if (targetFolderId) { - conditions.push(eq(workflow.folderId, targetFolderId)) - } else { - conditions.push(isNull(workflow.folderId)) + const updateData: Record = { updatedAt: new Date() } + if (updates.name !== undefined) updateData.name = updates.name + if (updates.description !== undefined) updateData.description = updates.description + if (updates.color !== undefined) updateData.color = updates.color + if (updates.folderId !== undefined) updateData.folderId = updates.folderId + if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder + + if (updates.name !== undefined || updates.folderId !== undefined) { + const targetName = updates.name ?? workflowData.name + const targetFolderId = + updates.folderId !== undefined ? updates.folderId : workflowData.folderId + + if (!workflowData.workspaceId) { + logger.error(`[${requestId}] Workflow ${workflowId} has no workspaceId`) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + const conditions = [ + eq(workflow.workspaceId, workflowData.workspaceId), + isNull(workflow.archivedAt), + eq(workflow.name, targetName), + ne(workflow.id, workflowId), + ] + + if (targetFolderId) { + conditions.push(eq(workflow.folderId, targetFolderId)) + } else { + conditions.push(isNull(workflow.folderId)) + } + + const [duplicate] = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(...conditions)) + .limit(1) + + if (duplicate) { + logger.warn( + `[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}` + ) + return NextResponse.json( + { error: `A workflow named "${targetName}" already exists in this folder` }, + { status: 409 } + ) + } } - const [duplicate] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(...conditions)) - .limit(1) + // Update the workflow + const [updatedWorkflow] = await db + .update(workflow) + .set(updateData) + .where(eq(workflow.id, workflowId)) + .returning() - if (duplicate) { - logger.warn( - `[${requestId}] Duplicate workflow name "${targetName}" in folder ${targetFolderId ?? 'root'}` - ) + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { + updates: updateData, + }) + + return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) + } catch (error: any) { + const elapsed = Date.now() - startTime + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid workflow update data for ${workflowId}`, { + errors: error.errors, + }) return NextResponse.json( - { error: `A workflow named "${targetName}" already exists in this folder` }, - { status: 409 } + { error: 'Invalid request data', details: error.errors }, + { status: 400 } ) } - } - // Update the workflow - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning() - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates: updateData, - }) - - return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid workflow update data for ${workflowId}`, { - errors: error.errors, - }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) + logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 26a63ecdd81..2bee55f5064 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { loadWorkflowFromNormalizedTables, @@ -117,234 +118,244 @@ const WorkflowStateSchema = z.object({ * Fetch the current workflow state from normalized tables. * Used by the client after server-side edits (edit_workflow) to stay in sync. */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workflowId } = await params +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workflowId } = await params - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: auth.userId, - action: 'read', - }) - if (!authorization.allowed) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: auth.userId, + action: 'read', + }) + if (!authorization.allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) - } + const normalized = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalized) { + return NextResponse.json({ error: 'Workflow state not found' }, { status: 404 }) + } - return NextResponse.json({ - blocks: normalized.blocks, - edges: normalized.edges, - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - }) - } catch (error) { - logger.error('Failed to fetch workflow state', { - workflowId, - error: error instanceof Error ? error.message : String(error), - }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + blocks: normalized.blocks, + edges: normalized.edges, + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + }) + } catch (error) { + logger.error('Failed to fetch workflow state', { + workflowId, + error: error instanceof Error ? error.message : String(error), + }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * PUT /api/workflows/[id]/state * Save complete workflow state to normalized database tables */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const startTime = Date.now() + const { id: workflowId } = await params - const body = await request.json() - const state = WorkflowStateSchema.parse(body) + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow + const body = await request.json() + const state = WorkflowStateSchema.parse(body) - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + const workflowData = authorization.workflow - const canUpdate = authorization.allowed + if (!workflowData) { + logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } + const canUpdate = authorization.allowed + + if (!canUpdate) { + logger.warn( + `[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}` + ) + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status || 403 } + ) + } + + // Sanitize custom tools in agent blocks before saving + const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( + state.blocks as Record ) - } - // Sanitize custom tools in agent blocks before saving - const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks( - state.blocks as Record - ) - - // Save to normalized tables - // Ensure all required fields are present for WorkflowState type - // Filter out blocks without type or name before saving - const filteredBlocks = Object.entries(sanitizedBlocks).reduce( - (acc, [blockId, block]: [string, BlockState]) => { - if (block.type && block.name) { - // Ensure all required fields are present - acc[blockId] = { - ...block, - enabled: block.enabled !== undefined ? block.enabled : true, - horizontalHandles: - block.horizontalHandles !== undefined ? block.horizontalHandles : true, - height: block.height !== undefined ? block.height : 0, - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, + // Save to normalized tables + // Ensure all required fields are present for WorkflowState type + // Filter out blocks without type or name before saving + const filteredBlocks = Object.entries(sanitizedBlocks).reduce( + (acc, [blockId, block]: [string, BlockState]) => { + if (block.type && block.name) { + // Ensure all required fields are present + acc[blockId] = { + ...block, + enabled: block.enabled !== undefined ? block.enabled : true, + horizontalHandles: + block.horizontalHandles !== undefined ? block.horizontalHandles : true, + height: block.height !== undefined ? block.height : 0, + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + } } - } - return acc - }, - {} as typeof state.blocks - ) - - const typedBlocks = filteredBlocks as Record - const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks) - const validationWarnings = validatedEdges.dropped.map( - ({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}` - ) - const canonicalLoops = generateLoopBlocks(typedBlocks) - const canonicalParallels = generateParallelBlocks(typedBlocks) - - const workflowState = { - blocks: filteredBlocks, - edges: validatedEdges.valid, - loops: canonicalLoops, - parallels: canonicalParallels, - lastSaved: state.lastSaved || Date.now(), - isDeployed: state.isDeployed || false, - deployedAt: state.deployedAt, - } + return acc + }, + {} as typeof state.blocks + ) - const saveResult = await saveWorkflowToNormalizedTables( - workflowId, - workflowState as WorkflowState - ) + const typedBlocks = filteredBlocks as Record + const validatedEdges = validateEdges(state.edges as WorkflowState['edges'], typedBlocks) + const validationWarnings = validatedEdges.dropped.map( + ({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}` + ) + const canonicalLoops = generateLoopBlocks(typedBlocks) + const canonicalParallels = generateParallelBlocks(typedBlocks) + + const workflowState = { + blocks: filteredBlocks, + edges: validatedEdges.valid, + loops: canonicalLoops, + parallels: canonicalParallels, + lastSaved: state.lastSaved || Date.now(), + isDeployed: state.isDeployed || false, + deployedAt: state.deployedAt, + } - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) - return NextResponse.json( - { error: 'Failed to save workflow state', details: saveResult.error }, - { status: 500 } + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + workflowState as WorkflowState ) - } - // Extract and persist custom tools to database - try { - const workspaceId = workflowData.workspaceId - if (workspaceId) { - const { saved, errors } = await extractAndPersistCustomTools( - workflowState, - workspaceId, - userId + if (!saveResult.success) { + logger.error( + `[${requestId}] Failed to save workflow ${workflowId} state:`, + saveResult.error ) - - if (saved > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { - workflowId, - }) - } - - if (errors.length > 0) { - logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId }) - } - } else { - logger.warn( - `[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`, - { - workflowId, - } + return NextResponse.json( + { error: 'Failed to save workflow state', details: saveResult.error }, + { status: 500 } ) } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) - } - - // Update workflow's lastSynced timestamp and variables if provided - const updateData: any = { - lastSynced: new Date(), - updatedAt: new Date(), - } - // If variables are provided in the state, update them in the workflow record - if (state.variables !== undefined) { - updateData.variables = state.variables - } + // Extract and persist custom tools to database + try { + const workspaceId = workflowData.workspaceId + if (workspaceId) { + const { saved, errors } = await extractAndPersistCustomTools( + workflowState, + workspaceId, + userId + ) + + if (saved > 0) { + logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { + workflowId, + }) + } - await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + if (errors.length > 0) { + logger.warn(`[${requestId}] Some custom tools failed to persist`, { + errors, + workflowId, + }) + } + } else { + logger.warn( + `[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`, + { + workflowId, + } + ) + } + } catch (error) { + logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) + } - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) + // Update workflow's lastSynced timestamp and variables if provided + const updateData: any = { + lastSynced: new Date(), + updatedAt: new Date(), + } - try { - const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' - const notifyResponse = await fetch(`${socketUrl}/api/workflow-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }) + // If variables are provided in the state, update them in the workflow record + if (state.variables !== undefined) { + updateData.variables = state.variables + } - if (!notifyResponse.ok) { + await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + const elapsed = Date.now() - startTime + logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) + + try { + const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' + const notifyResponse = await fetch(`${socketUrl}/api/workflow-updated`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId }), + }) + + if (!notifyResponse.ok) { + logger.warn( + `[${requestId}] Failed to notify Socket.IO server about workflow ${workflowId} update` + ) + } + } catch (notificationError) { logger.warn( - `[${requestId}] Failed to notify Socket.IO server about workflow ${workflowId} update` + `[${requestId}] Error notifying Socket.IO server about workflow ${workflowId} update`, + notificationError ) } - } catch (notificationError) { - logger.warn( - `[${requestId}] Error notifying Socket.IO server about workflow ${workflowId} update`, - notificationError - ) - } - return NextResponse.json( - { success: true, warnings: [...warnings, ...validationWarnings] }, - { status: 200 } - ) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, - error - ) - - if (error instanceof z.ZodError) { return NextResponse.json( - { error: 'Invalid request body', details: error.errors }, - { status: 400 } + { success: true, warnings: [...warnings, ...validationWarnings] }, + { status: 200 } + ) + } catch (error: any) { + const elapsed = Date.now() - startTime + logger.error( + `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, + error ) - } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request body', details: error.errors }, + { status: 400 } + ) + } + + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index ac428d414fb..c3b578d5a38 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { checkNeedsRedeployment, @@ -10,30 +11,32 @@ import { const logger = createLogger('WorkflowStatusAPI') -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() - try { - const { id } = await params + try { + const { id } = await params - const validation = await validateWorkflowAccess(request, id, false) - if (validation.error) { - logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) - return createErrorResponse(validation.error.message, validation.error.status) - } + const validation = await validateWorkflowAccess(request, id, false) + if (validation.error) { + logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`) + return createErrorResponse(validation.error.message, validation.error.status) + } - const needsRedeployment = validation.workflow.isDeployed - ? await checkNeedsRedeployment(id) - : false + const needsRedeployment = validation.workflow.isDeployed + ? await checkNeedsRedeployment(id) + : false - return createSuccessResponse({ - isDeployed: validation.workflow.isDeployed, - deployedAt: validation.workflow.deployedAt, - isPublished: validation.workflow.isPublished, - needsRedeployment, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) - return createErrorResponse('Failed to get status', 500) + return createSuccessResponse({ + isDeployed: validation.workflow.isDeployed, + deployedAt: validation.workflow.deployedAt, + isPublished: validation.workflow.isPublished, + needsRedeployment, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) + return createErrorResponse('Failed to get status', 500) + } } -} +) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 1b4cd8ab3b9..80ae49b7675 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import type { Variable } from '@/stores/variables/types' @@ -30,144 +31,148 @@ const VariablesSchema = z.object({ variables: z.record(z.string(), VariableSchema), }) -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workflowId = (await params).id +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workflowId = (await params).id - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - const workflowData = authorization.workflow - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - const isAuthorized = authorization.allowed + try { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId - if (!isAuthorized) { - logger.warn( - `[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission` - ) - return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } - ) + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + const workflowData = authorization.workflow + + if (!workflowData) { + logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + const isAuthorized = authorization.allowed + + if (!isAuthorized) { + logger.warn( + `[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission` + ) + return NextResponse.json( + { error: authorization.message || 'Access denied' }, + { status: authorization.status || 403 } + ) + } + + const body = await req.json() + + try { + const { variables } = VariablesSchema.parse(body) + + // Variables are already in Record format - use directly + // The frontend is the source of truth for what variables should exist + await db + .update(workflow) + .set({ + variables, + updatedAt: new Date(), + }) + .where(eq(workflow.id, workflowId)) + + recordAudit({ + workspaceId: workflowData.workspaceId ?? null, + actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowData.name ?? undefined, + description: `Updated workflow variables`, + metadata: { variableCount: Object.keys(variables).length }, + request: req, + }) + + return NextResponse.json({ success: true }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid workflow variables data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error updating workflow variables`, error) + return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 }) } + } +) - const body = await req.json() +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workflowId = (await params).id try { - const { variables } = VariablesSchema.parse(body) - - // Variables are already in Record format - use directly - // The frontend is the source of truth for what variables should exist - await db - .update(workflow) - .set({ - variables, - updatedAt: new Date(), - }) - .where(eq(workflow.id, workflowId)) - - recordAudit({ - workspaceId: workflowData.workspaceId ?? null, - actorId: userId, - actorName: auth.userName, - actorEmail: auth.userEmail, - action: AuditAction.WORKFLOW_VARIABLES_UPDATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: workflowData.name ?? undefined, - description: `Updated workflow variables`, - metadata: { variableCount: Object.keys(variables).length }, - request: req, + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = auth.userId + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', }) + const workflowData = authorization.workflow - return NextResponse.json({ success: true }) - } catch (validationError) { - if (validationError instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid workflow variables data`, { - errors: validationError.errors, - }) + if (!workflowData) { + logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + const isAuthorized = authorization.allowed + + if (!isAuthorized) { + logger.warn( + `[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission` + ) return NextResponse.json( - { error: 'Invalid request data', details: validationError.errors }, - { status: 400 } + { error: authorization.message || 'Access denied' }, + { status: authorization.status || 403 } ) } - throw validationError - } - } catch (error) { - logger.error(`[${requestId}] Error updating workflow variables`, error) - return NextResponse.json({ error: 'Failed to update workflow variables' }, { status: 500 }) - } -} -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workflowId = (await params).id + // Return variables if they exist + const variables = (workflowData.variables as Record) || {} - try { - const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = auth.userId - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'read', - }) - const workflowData = authorization.workflow - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - const isAuthorized = authorization.allowed + // Add cache headers to prevent frequent reloading + const variableHash = JSON.stringify(variables).length + const headers = new Headers({ + 'Cache-Control': 'max-age=30, stale-while-revalidate=300', // Cache for 30 seconds, stale for 5 min + ETag: `"variables-${workflowId}-${variableHash}"`, + }) - if (!isAuthorized) { - logger.warn( - `[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission` - ) return NextResponse.json( - { error: authorization.message || 'Access denied' }, - { status: authorization.status || 403 } + { data: variables }, + { + status: 200, + headers, + } ) + } catch (error) { + logger.error(`[${requestId}] Workflow variables fetch error`, error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: errorMessage }, { status: 500 }) } - - // Return variables if they exist - const variables = (workflowData.variables as Record) || {} - - // Add cache headers to prevent frequent reloading - const variableHash = JSON.stringify(variables).length - const headers = new Headers({ - 'Cache-Control': 'max-age=30, stale-while-revalidate=300', // Cache for 30 seconds, stale for 5 min - ETag: `"variables-${workflowId}-${variableHash}"`, - }) - - return NextResponse.json( - { data: variables }, - { - status: 200, - headers, - } - ) - } catch (error) { - logger.error(`[${requestId}] Workflow variables fetch error`, error) - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workflows/reorder/route.ts b/apps/sim/app/api/workflows/reorder/route.ts index d574ea1e663..dbd2980db3b 100644 --- a/apps/sim/app/api/workflows/reorder/route.ts +++ b/apps/sim/app/api/workflows/reorder/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' 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('WorkflowReorderAPI') @@ -21,7 +22,7 @@ const ReorderSchema = z.object({ ), }) -export async function PUT(req: NextRequest) { +export const PUT = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -88,4 +89,4 @@ export async function PUT(req: NextRequest) { logger.error(`[${requestId}] Error reordering workflows`, error) return NextResponse.json({ error: 'Failed to reorder workflows' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 3615afd0890..4f6d0cd5d3c 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -33,7 +34,7 @@ const CreateWorkflowSchema = z.object({ }) // GET /api/workflows - Get workflows for user (optionally filtered by workspaceId) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() const url = new URL(request.url) @@ -115,10 +116,10 @@ export async function GET(request: NextRequest) { logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error) return NextResponse.json({ error: error.message }, { status: 500 }) } -} +}) // POST /api/workflows - Create a new workflow -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) if (!auth.success || !auth.userId) { @@ -327,4 +328,4 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error creating workflow`, error) return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index 42711f1fa8c..84025f028f7 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -16,164 +17,176 @@ const UpdateKeySchema = z.object({ name: z.string().min(1, 'Name is required'), }) -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ id: string; keyId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, keyId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API key update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const body = await request.json() - const { name } = UpdateKeySchema.parse(body) - - const existingKey = await db - .select() - .from(apiKey) - .where( - and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace')) - ) - .limit(1) - - if (existingKey.length === 0) { - return NextResponse.json({ error: 'API key not found' }, { status: 404 }) - } +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; keyId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, keyId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace API key update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await request.json() + const { name } = UpdateKeySchema.parse(body) + + const existingKey = await db + .select() + .from(apiKey) + .where( + and( + eq(apiKey.workspaceId, workspaceId), + eq(apiKey.id, keyId), + eq(apiKey.type, 'workspace') + ) + ) + .limit(1) + + if (existingKey.length === 0) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }) + } + + const conflictingKey = await db + .select() + .from(apiKey) + .where( + and( + eq(apiKey.workspaceId, workspaceId), + eq(apiKey.name, name), + eq(apiKey.type, 'workspace'), + not(eq(apiKey.id, keyId)) + ) + ) + .limit(1) - const conflictingKey = await db - .select() - .from(apiKey) - .where( - and( - eq(apiKey.workspaceId, workspaceId), - eq(apiKey.name, name), - eq(apiKey.type, 'workspace'), - not(eq(apiKey.id, keyId)) + if (conflictingKey.length > 0) { + return NextResponse.json( + { error: 'A workspace API key with this name already exists' }, + { status: 400 } ) - ) - .limit(1) + } + + const [updatedKey] = await db + .update(apiKey) + .set({ + name, + updatedAt: new Date(), + }) + .where( + and( + eq(apiKey.workspaceId, workspaceId), + eq(apiKey.id, keyId), + eq(apiKey.type, 'workspace') + ) + ) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + updatedAt: apiKey.updatedAt, + }) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.API_KEY_UPDATED, + resourceType: AuditResourceType.API_KEY, + resourceId: keyId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: name, + description: `Updated workspace API key: ${name}`, + request, + }) - if (conflictingKey.length > 0) { + logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`) + return NextResponse.json({ key: updatedKey }) + } catch (error: unknown) { + logger.error(`[${requestId}] Workspace API key PUT error`, error) return NextResponse.json( - { error: 'A workspace API key with this name already exists' }, - { status: 400 } + { error: error instanceof Error ? error.message : 'Failed to update workspace API key' }, + { status: 500 } ) } - - const [updatedKey] = await db - .update(apiKey) - .set({ - name, - updatedAt: new Date(), - }) - .where( - and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace')) - ) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - updatedAt: apiKey.updatedAt, - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.API_KEY_UPDATED, - resourceType: AuditResourceType.API_KEY, - resourceId: keyId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Updated workspace API key: ${name}`, - request, - }) - - logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`) - return NextResponse.json({ key: updatedKey }) - } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API key PUT error`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to update workspace API key' }, - { status: 500 } - ) } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string; keyId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, keyId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; keyId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, keyId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const deletedRows = await db + .delete(apiKey) + .where( + and( + eq(apiKey.workspaceId, workspaceId), + eq(apiKey.id, keyId), + eq(apiKey.type, 'workspace') + ) + ) + .returning({ id: apiKey.id, name: apiKey.name, lastUsed: apiKey.lastUsed }) - const userId = session.user.id + if (deletedRows.length === 0) { + return NextResponse.json({ error: 'API key not found' }, { status: 404 }) + } - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const deletedKey = deletedRows[0] - const deletedRows = await db - .delete(apiKey) - .where( - and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace')) + captureServerEvent( + userId, + 'api_key_revoked', + { workspace_id: workspaceId, key_name: deletedKey.name }, + { groups: { workspace: workspaceId } } ) - .returning({ id: apiKey.id, name: apiKey.name, lastUsed: apiKey.lastUsed }) - if (deletedRows.length === 0) { - return NextResponse.json({ error: 'API key not found' }, { status: 404 }) - } + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.API_KEY_REVOKED, + resourceType: AuditResourceType.API_KEY, + resourceId: keyId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: deletedKey.name, + description: `Revoked workspace API key: ${deletedKey.name}`, + metadata: { lastUsed: deletedKey.lastUsed?.toISOString() ?? null }, + request, + }) - const deletedKey = deletedRows[0] - - captureServerEvent( - userId, - 'api_key_revoked', - { workspace_id: workspaceId, key_name: deletedKey.name }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.API_KEY_REVOKED, - resourceType: AuditResourceType.API_KEY, - resourceId: keyId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: deletedKey.name, - description: `Revoked workspace API key: ${deletedKey.name}`, - metadata: { lastUsed: deletedKey.lastUsed?.toISOString() ?? null }, - request, - }) - - logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`) - return NextResponse.json({ success: true }) - } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API key DELETE error`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete workspace API key' }, - { status: 500 } - ) + logger.info( + `[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}` + ) + return NextResponse.json({ success: true }) + } catch (error: unknown) { + logger.error(`[${requestId}] Workspace API key DELETE error`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete workspace API key' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index a6a15bb52f2..768666a50fc 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -24,250 +25,253 @@ const DeleteKeysSchema = z.object({ keys: z.array(z.string()).min(1), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workspaceId = (await params).id +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API keys access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace API keys access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = session.user.id + const userId = session.user.id - const ws = await getWorkspaceById(workspaceId) - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + const ws = await getWorkspaceById(workspaceId) + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const workspaceKeys = await db - .select({ - id: apiKey.id, - name: apiKey.name, - key: apiKey.key, - createdAt: apiKey.createdAt, - lastUsed: apiKey.lastUsed, - expiresAt: apiKey.expiresAt, - createdBy: apiKey.createdBy, - }) - .from(apiKey) - .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) - .orderBy(apiKey.createdAt) - - const formattedWorkspaceKeys = await Promise.all( - workspaceKeys.map(async (key) => { - const displayFormat = await getApiKeyDisplayFormat(key.key) - return { - ...key, - key: key.key, - displayKey: displayFormat, - } + const workspaceKeys = await db + .select({ + id: apiKey.id, + name: apiKey.name, + key: apiKey.key, + createdAt: apiKey.createdAt, + lastUsed: apiKey.lastUsed, + expiresAt: apiKey.expiresAt, + createdBy: apiKey.createdBy, + }) + .from(apiKey) + .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) + .orderBy(apiKey.createdAt) + + const formattedWorkspaceKeys = await Promise.all( + workspaceKeys.map(async (key) => { + const displayFormat = await getApiKeyDisplayFormat(key.key) + return { + ...key, + key: key.key, + displayKey: displayFormat, + } + }) + ) + + return NextResponse.json({ + keys: formattedWorkspaceKeys, }) - ) - - return NextResponse.json({ - keys: formattedWorkspaceKeys, - }) - } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API keys GET error`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to load API keys' }, - { status: 500 } - ) + } catch (error: unknown) { + logger.error(`[${requestId}] Workspace API keys GET error`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to load API keys' }, + { status: 500 } + ) + } } -} +) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workspaceId = (await params).id +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = session.user.id + const userId = session.user.id - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - const body = await request.json() - const { name, source } = CreateKeySchema.parse(body) - - const existingKey = await db - .select() - .from(apiKey) - .where( - and( - eq(apiKey.workspaceId, workspaceId), - eq(apiKey.name, name), - eq(apiKey.type, 'workspace') + const body = await request.json() + const { name, source } = CreateKeySchema.parse(body) + + const existingKey = await db + .select() + .from(apiKey) + .where( + and( + eq(apiKey.workspaceId, workspaceId), + eq(apiKey.name, name), + eq(apiKey.type, 'workspace') + ) ) - ) - .limit(1) + .limit(1) + + if (existingKey.length > 0) { + return NextResponse.json( + { + error: `A workspace API key named "${name}" already exists. Please choose a different name.`, + }, + { status: 409 } + ) + } - if (existingKey.length > 0) { - return NextResponse.json( + const { key: plainKey, encryptedKey } = await createApiKey(true) + + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [newKey] = await db + .insert(apiKey) + .values({ + id: generateShortId(), + workspaceId, + userId: userId, + createdBy: userId, + name, + key: encryptedKey, + type: 'workspace', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + }) + + try { + PlatformEvents.apiKeyGenerated({ + userId: userId, + keyName: name, + }) + } catch { + // Telemetry should not fail the operation + } + + captureServerEvent( + userId, + 'api_key_created', + { workspace_id: workspaceId, key_name: name, source }, { - error: `A workspace API key named "${name}" already exists. Please choose a different name.`, - }, - { status: 409 } + groups: { workspace: workspaceId }, + setOnce: { first_api_key_created_at: new Date().toISOString() }, + } ) - } - const { key: plainKey, encryptedKey } = await createApiKey(true) + logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: generateShortId(), + recordAudit({ workspaceId, - userId: userId, - createdBy: userId, - name, - key: encryptedKey, - type: 'workspace', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: newKey.id, + resourceName: name, + description: `Created API key "${name}"`, + metadata: { keyName: name }, + request, }) - try { - PlatformEvents.apiKeyGenerated({ - userId: userId, - keyName: name, + return NextResponse.json({ + key: { + ...newKey, + key: plainKey, + }, }) - } catch { - // Telemetry should not fail the operation + } catch (error: unknown) { + logger.error(`[${requestId}] Workspace API key POST error`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create workspace API key' }, + { status: 500 } + ) } - - captureServerEvent( - userId, - 'api_key_created', - { workspace_id: workspaceId, key_name: name, source }, - { - groups: { workspace: workspaceId }, - setOnce: { first_api_key_created_at: new Date().toISOString() }, - } - ) - - logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.API_KEY_CREATED, - resourceType: AuditResourceType.API_KEY, - resourceId: newKey.id, - resourceName: name, - description: `Created API key "${name}"`, - metadata: { keyName: name }, - request, - }) - - return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, - }) - } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API key POST error`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to create workspace API key' }, - { status: 500 } - ) } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const workspaceId = (await params).id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +) - const userId = session.user.id +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id - const body = await request.json() - const { keys } = DeleteKeysSchema.parse(body) + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - const deletedCount = await db - .delete(apiKey) - .where( - and( - eq(apiKey.workspaceId, workspaceId), - eq(apiKey.type, 'workspace'), - inArray(apiKey.id, keys) + const body = await request.json() + const { keys } = DeleteKeysSchema.parse(body) + + const deletedCount = await db + .delete(apiKey) + .where( + and( + eq(apiKey.workspaceId, workspaceId), + eq(apiKey.type, 'workspace'), + inArray(apiKey.id, keys) + ) ) - ) - try { - for (const keyId of keys) { - PlatformEvents.apiKeyRevoked({ - userId: userId, - keyId: keyId, - }) + try { + for (const keyId of keys) { + PlatformEvents.apiKeyRevoked({ + userId: userId, + keyId: keyId, + }) + } + } catch { + // Telemetry should not fail the operation } - } catch { - // Telemetry should not fail the operation - } - logger.info( - `[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}` - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.API_KEY_REVOKED, - resourceType: AuditResourceType.API_KEY, - description: `Revoked ${deletedCount} API key(s)`, - metadata: { keyIds: keys, deletedCount }, - request, - }) - - return NextResponse.json({ success: true, deletedCount }) - } catch (error: unknown) { - logger.error(`[${requestId}] Workspace API key DELETE error`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete workspace API keys' }, - { status: 500 } - ) + logger.info( + `[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}` + ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.API_KEY_REVOKED, + resourceType: AuditResourceType.API_KEY, + description: `Revoked ${deletedCount} API key(s)`, + metadata: { keyIds: keys, deletedCount }, + request, + }) + + return NextResponse.json({ success: true, deletedCount }) + } catch (error: unknown) { + logger.error(`[${requestId}] Workspace API key DELETE error`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete workspace API keys' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 65f177b1c55..2ff5e2b4161 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { generateShortId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -50,268 +51,274 @@ function maskApiKey(key: string): string { return `${key.slice(0, 6)}...${key.slice(-4)}` } -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workspaceId = (await params).id +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized BYOK keys access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized BYOK keys access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = session.user.id + const userId = session.user.id - const ws = await getWorkspaceById(workspaceId) - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + const ws = await getWorkspaceById(workspaceId) + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const byokKeys = await db - .select({ - id: workspaceBYOKKeys.id, - providerId: workspaceBYOKKeys.providerId, - encryptedApiKey: workspaceBYOKKeys.encryptedApiKey, - createdBy: workspaceBYOKKeys.createdBy, - createdAt: workspaceBYOKKeys.createdAt, - updatedAt: workspaceBYOKKeys.updatedAt, - }) - .from(workspaceBYOKKeys) - .where(eq(workspaceBYOKKeys.workspaceId, workspaceId)) - .orderBy(workspaceBYOKKeys.providerId) - - const formattedKeys = await Promise.all( - byokKeys.map(async (key) => { - try { - const { decrypted } = await decryptSecret(key.encryptedApiKey) - return { - id: key.id, - providerId: key.providerId, - maskedKey: maskApiKey(decrypted), - createdBy: key.createdBy, - createdAt: key.createdAt, - updatedAt: key.updatedAt, - } - } catch (error) { - logger.error(`[${requestId}] Failed to decrypt BYOK key for provider ${key.providerId}`, { - error, - }) - return { - id: key.id, - providerId: key.providerId, - maskedKey: '••••••••', - createdBy: key.createdBy, - createdAt: key.createdAt, - updatedAt: key.updatedAt, + const byokKeys = await db + .select({ + id: workspaceBYOKKeys.id, + providerId: workspaceBYOKKeys.providerId, + encryptedApiKey: workspaceBYOKKeys.encryptedApiKey, + createdBy: workspaceBYOKKeys.createdBy, + createdAt: workspaceBYOKKeys.createdAt, + updatedAt: workspaceBYOKKeys.updatedAt, + }) + .from(workspaceBYOKKeys) + .where(eq(workspaceBYOKKeys.workspaceId, workspaceId)) + .orderBy(workspaceBYOKKeys.providerId) + + const formattedKeys = await Promise.all( + byokKeys.map(async (key) => { + try { + const { decrypted } = await decryptSecret(key.encryptedApiKey) + return { + id: key.id, + providerId: key.providerId, + maskedKey: maskApiKey(decrypted), + createdBy: key.createdBy, + createdAt: key.createdAt, + updatedAt: key.updatedAt, + } + } catch (error) { + logger.error( + `[${requestId}] Failed to decrypt BYOK key for provider ${key.providerId}`, + { + error, + } + ) + return { + id: key.id, + providerId: key.providerId, + maskedKey: '••••••••', + createdBy: key.createdBy, + createdAt: key.createdAt, + updatedAt: key.updatedAt, + } } - } - }) - ) - - return NextResponse.json({ keys: formattedKeys }) - } catch (error: unknown) { - logger.error(`[${requestId}] BYOK keys GET error`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to load BYOK keys' }, - { status: 500 } - ) - } -} - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workspaceId = (await params).id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized BYOK key creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id + }) + ) - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin') { + return NextResponse.json({ keys: formattedKeys }) + } catch (error: unknown) { + logger.error(`[${requestId}] BYOK keys GET error`, error) return NextResponse.json( - { error: 'Only workspace admins can manage BYOK keys' }, - { status: 403 } + { error: error instanceof Error ? error.message : 'Failed to load BYOK keys' }, + { status: 500 } ) } + } +) + +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized BYOK key creation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json( + { error: 'Only workspace admins can manage BYOK keys' }, + { status: 403 } + ) + } - const body = await request.json() - const { providerId, apiKey } = UpsertKeySchema.parse(body) + const body = await request.json() + const { providerId, apiKey } = UpsertKeySchema.parse(body) - const { encrypted } = await encryptSecret(apiKey) + const { encrypted } = await encryptSecret(apiKey) - const existingKey = await db - .select() - .from(workspaceBYOKKeys) - .where( - and( - eq(workspaceBYOKKeys.workspaceId, workspaceId), - eq(workspaceBYOKKeys.providerId, providerId) + const existingKey = await db + .select() + .from(workspaceBYOKKeys) + .where( + and( + eq(workspaceBYOKKeys.workspaceId, workspaceId), + eq(workspaceBYOKKeys.providerId, providerId) + ) ) - ) - .limit(1) + .limit(1) + + if (existingKey.length > 0) { + await db + .update(workspaceBYOKKeys) + .set({ + encryptedApiKey: encrypted, + updatedAt: new Date(), + }) + .where(eq(workspaceBYOKKeys.id, existingKey[0].id)) + + logger.info(`[${requestId}] Updated BYOK key for ${providerId} in workspace ${workspaceId}`) + + return NextResponse.json({ + success: true, + key: { + id: existingKey[0].id, + providerId, + maskedKey: maskApiKey(apiKey), + updatedAt: new Date(), + }, + }) + } - if (existingKey.length > 0) { - await db - .update(workspaceBYOKKeys) - .set({ + const [newKey] = await db + .insert(workspaceBYOKKeys) + .values({ + id: generateShortId(), + workspaceId, + providerId, encryptedApiKey: encrypted, + createdBy: userId, + createdAt: new Date(), updatedAt: new Date(), }) - .where(eq(workspaceBYOKKeys.id, existingKey[0].id)) + .returning({ + id: workspaceBYOKKeys.id, + providerId: workspaceBYOKKeys.providerId, + createdAt: workspaceBYOKKeys.createdAt, + }) - logger.info(`[${requestId}] Updated BYOK key for ${providerId} in workspace ${workspaceId}`) + logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`) + + captureServerEvent( + userId, + 'byok_key_added', + { workspace_id: workspaceId, provider_id: providerId }, + { + groups: { workspace: workspaceId }, + setOnce: { first_byok_key_added_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.BYOK_KEY_CREATED, + resourceType: AuditResourceType.BYOK_KEY, + resourceId: newKey.id, + resourceName: providerId, + description: `Added BYOK key for ${providerId}`, + metadata: { providerId }, + request, + }) return NextResponse.json({ success: true, key: { - id: existingKey[0].id, - providerId, + ...newKey, maskedKey: maskApiKey(apiKey), - updatedAt: new Date(), }, }) + } catch (error: unknown) { + logger.error(`[${requestId}] BYOK key POST error`, error) + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to save BYOK key' }, + { status: 500 } + ) } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized BYOK key deletion attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [newKey] = await db - .insert(workspaceBYOKKeys) - .values({ - id: generateShortId(), - workspaceId, - providerId, - encryptedApiKey: encrypted, - createdBy: userId, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: workspaceBYOKKeys.id, - providerId: workspaceBYOKKeys.providerId, - createdAt: workspaceBYOKKeys.createdAt, - }) - - logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`) + const userId = session.user.id - captureServerEvent( - userId, - 'byok_key_added', - { workspace_id: workspaceId, provider_id: providerId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_byok_key_added_at: new Date().toISOString() }, + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json( + { error: 'Only workspace admins can manage BYOK keys' }, + { status: 403 } + ) } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.BYOK_KEY_CREATED, - resourceType: AuditResourceType.BYOK_KEY, - resourceId: newKey.id, - resourceName: providerId, - description: `Added BYOK key for ${providerId}`, - metadata: { providerId }, - request, - }) - - return NextResponse.json({ - success: true, - key: { - ...newKey, - maskedKey: maskApiKey(apiKey), - }, - }) - } catch (error: unknown) { - logger.error(`[${requestId}] BYOK key POST error`, error) - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) - } - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to save BYOK key' }, - { status: 500 } - ) - } -} -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const workspaceId = (await params).id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized BYOK key deletion attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const body = await request.json() + const { providerId } = DeleteKeySchema.parse(body) + + const result = await db + .delete(workspaceBYOKKeys) + .where( + and( + eq(workspaceBYOKKeys.workspaceId, workspaceId), + eq(workspaceBYOKKeys.providerId, providerId) + ) + ) - const userId = session.user.id + logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`) - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json( - { error: 'Only workspace admins can manage BYOK keys' }, - { status: 403 } + captureServerEvent( + userId, + 'byok_key_removed', + { workspace_id: workspaceId, provider_id: providerId }, + { groups: { workspace: workspaceId } } ) - } - const body = await request.json() - const { providerId } = DeleteKeySchema.parse(body) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.BYOK_KEY_DELETED, + resourceType: AuditResourceType.BYOK_KEY, + resourceName: providerId, + description: `Removed BYOK key for ${providerId}`, + metadata: { providerId }, + request, + }) - const result = await db - .delete(workspaceBYOKKeys) - .where( - and( - eq(workspaceBYOKKeys.workspaceId, workspaceId), - eq(workspaceBYOKKeys.providerId, providerId) - ) + return NextResponse.json({ success: true }) + } catch (error: unknown) { + logger.error(`[${requestId}] BYOK key DELETE error`, error) + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete BYOK key' }, + { status: 500 } ) - - logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`) - - captureServerEvent( - userId, - 'byok_key_removed', - { workspace_id: workspaceId, provider_id: providerId }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.BYOK_KEY_DELETED, - resourceType: AuditResourceType.BYOK_KEY, - resourceName: providerId, - description: `Removed BYOK key for ${providerId}`, - metadata: { providerId }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error: unknown) { - logger.error(`[${requestId}] BYOK key DELETE error`, error) - if (error instanceof z.ZodError) { - return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) } - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete BYOK key' }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts index c8f29e2a7bd..d0b1bbaf922 100644 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { duplicateWorkspace } from '@/lib/workspaces/duplicate' const logger = createLogger('WorkspaceDuplicateAPI') @@ -13,85 +14,87 @@ const DuplicateRequestSchema = z.object({ }) // POST /api/workspaces/[id]/duplicate - Duplicate a workspace with all its workflows -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: sourceWorkspaceId } = await params - const requestId = generateRequestId() - const startTime = Date.now() +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: sourceWorkspaceId } = await params + const requestId = generateRequestId() + const startTime = Date.now() - const session = await getSession() - if (!session?.user?.id) { - logger.warn( - `[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const session = await getSession() + if (!session?.user?.id) { + logger.warn( + `[${requestId}] Unauthorized workspace duplication attempt for ${sourceWorkspaceId}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - try { - const body = await req.json() - const { name } = DuplicateRequestSchema.parse(body) + try { + const body = await req.json() + const { name } = DuplicateRequestSchema.parse(body) + + logger.info( + `[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}` + ) - logger.info( - `[${requestId}] Duplicating workspace ${sourceWorkspaceId} for user ${session.user.id}` - ) + const result = await duplicateWorkspace({ + sourceWorkspaceId, + userId: session.user.id, + name, + requestId, + }) - const result = await duplicateWorkspace({ - sourceWorkspaceId, - userId: session.user.id, - name, - requestId, - }) + const elapsed = Date.now() - startTime + logger.info( + `[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms` + ) - const elapsed = Date.now() - startTime - logger.info( - `[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms` - ) + recordAudit({ + workspaceId: sourceWorkspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_DUPLICATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: result.id, + resourceName: name, + description: `Duplicated workspace to "${name}"`, + metadata: { + sourceWorkspaceId, + affected: { workflows: result.workflowsCount, folders: result.foldersCount }, + }, + request: req, + }) - recordAudit({ - workspaceId: sourceWorkspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.WORKSPACE_DUPLICATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: result.id, - resourceName: name, - description: `Duplicated workspace to "${name}"`, - metadata: { - sourceWorkspaceId, - affected: { workflows: result.workflowsCount, folders: result.foldersCount }, - }, - request: req, - }) + return NextResponse.json(result, { status: 201 }) + } catch (error) { + if (error instanceof Error) { + if (error.message === 'Source workspace not found') { + logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`) + return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 }) + } - return NextResponse.json(result, { status: 201 }) - } catch (error) { - if (error instanceof Error) { - if (error.message === 'Source workspace not found') { - logger.warn(`[${requestId}] Source workspace ${sourceWorkspaceId} not found`) - return NextResponse.json({ error: 'Source workspace not found' }, { status: 404 }) + if (error.message === 'Source workspace not found or access denied') { + logger.warn( + `[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } } - if (error.message === 'Source workspace not found or access denied') { - logger.warn( - `[${requestId}] User ${session.user.id} denied access to source workspace ${sourceWorkspaceId}` + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - } - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid duplication request data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } + const elapsed = Date.now() - startTime + logger.error( + `[${requestId}] Error duplicating workspace ${sourceWorkspaceId} after ${elapsed}ms:`, + error ) + return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 }) } - - const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error duplicating workspace ${sourceWorkspaceId} after ${elapsed}ms:`, - error - ) - return NextResponse.json({ error: 'Failed to duplicate workspace' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 2e118b628d7..986aa7c28b3 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -23,206 +24,209 @@ const DeleteSchema = z.object({ keys: z.array(z.string()).min(1), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workspaceId = (await params).id +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace env access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace env access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = session.user.id + const userId = session.user.id - // Validate workspace exists - const ws = await getWorkspaceById(workspaceId) - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + // Validate workspace exists + const ws = await getWorkspaceById(workspaceId) + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - // Require any permission to read - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Require any permission to read + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( - userId, - workspaceId - ) - - return NextResponse.json( - { - data: { - workspace: workspaceDecrypted, - personal: personalDecrypted, - conflicts, + const { workspaceDecrypted, personalDecrypted, conflicts } = await getPersonalAndWorkspaceEnv( + userId, + workspaceId + ) + + return NextResponse.json( + { + data: { + workspace: workspaceDecrypted, + personal: personalDecrypted, + conflicts, + }, }, - }, - { status: 200 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Workspace env GET error`, error) - return NextResponse.json( - { error: error.message || 'Failed to load environment' }, - { status: 500 } - ) - } -} - -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const workspaceId = (await params).id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace env update attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission || (permission !== 'admin' && permission !== 'write')) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + { status: 200 } + ) + } catch (error: any) { + logger.error(`[${requestId}] Workspace env GET error`, error) + return NextResponse.json( + { error: error.message || 'Failed to load environment' }, + { status: 500 } + ) } + } +) + +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace env update attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const body = await request.json() - const { variables } = UpsertSchema.parse(body) - - // Read existing encrypted ws vars - const existingRows = await db - .select() - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - - const existingEncrypted: Record = (existingRows[0]?.variables as any) || {} + const userId = session.user.id + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission || (permission !== 'admin' && permission !== 'write')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - // Encrypt incoming - const encryptedIncoming = await Promise.all( - Object.entries(variables).map(async ([key, value]) => { - const { encrypted } = await encryptSecret(value) - return [key, encrypted] as const + const body = await request.json() + const { variables } = UpsertSchema.parse(body) + + // Read existing encrypted ws vars + const existingRows = await db + .select() + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + const existingEncrypted: Record = (existingRows[0]?.variables as any) || {} + + // Encrypt incoming + const encryptedIncoming = await Promise.all( + Object.entries(variables).map(async ([key, value]) => { + const { encrypted } = await encryptSecret(value) + return [key, encrypted] as const + }) + ).then((entries) => Object.fromEntries(entries)) + + const merged = { ...existingEncrypted, ...encryptedIncoming } + + // Upsert by unique workspace_id + await db + .insert(workspaceEnvironment) + .values({ + id: generateId(), + workspaceId, + variables: merged, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: merged, updatedAt: new Date() }, + }) + + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: Object.keys(merged), + actingUserId: userId, }) - ).then((entries) => Object.fromEntries(entries)) - - const merged = { ...existingEncrypted, ...encryptedIncoming } - // Upsert by unique workspace_id - await db - .insert(workspaceEnvironment) - .values({ - id: generateId(), + recordAudit({ workspaceId, - variables: merged, - createdAt: new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: merged, updatedAt: new Date() }, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: workspaceId, + description: `Updated environment variables`, + metadata: { variableCount: Object.keys(variables).length }, + request, }) - await syncWorkspaceEnvCredentials({ - workspaceId, - envKeys: Object.keys(merged), - actingUserId: userId, - }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.ENVIRONMENT_UPDATED, - resourceType: AuditResourceType.ENVIRONMENT, - resourceId: workspaceId, - description: `Updated environment variables`, - metadata: { variableCount: Object.keys(variables).length }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error: any) { - logger.error(`[${requestId}] Workspace env PUT error`, error) - return NextResponse.json( - { error: error.message || 'Failed to update environment' }, - { status: 500 } - ) - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const workspaceId = (await params).id - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ success: true }) + } catch (error: any) { + logger.error(`[${requestId}] Workspace env PUT error`, error) + return NextResponse.json( + { error: error.message || 'Failed to update environment' }, + { status: 500 } + ) } + } +) + +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const workspaceId = (await params).id + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized workspace env delete attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userId = session.user.id - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission || (permission !== 'admin' && permission !== 'write')) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + const userId = session.user.id + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission || (permission !== 'admin' && permission !== 'write')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - const body = await request.json() - const { keys } = DeleteSchema.parse(body) - - const wsRows = await db - .select() - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - - const current: Record = (wsRows[0]?.variables as any) || {} - let changed = false - for (const k of keys) { - if (k in current) { - delete current[k] - changed = true + const body = await request.json() + const { keys } = DeleteSchema.parse(body) + + const wsRows = await db + .select() + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + + const current: Record = (wsRows[0]?.variables as any) || {} + let changed = false + for (const k of keys) { + if (k in current) { + delete current[k] + changed = true + } } - } - if (!changed) { - return NextResponse.json({ success: true }) - } + if (!changed) { + return NextResponse.json({ success: true }) + } - await db - .insert(workspaceEnvironment) - .values({ - id: wsRows[0]?.id || generateId(), + await db + .insert(workspaceEnvironment) + .values({ + id: wsRows[0]?.id || generateId(), + workspaceId, + variables: current, + createdAt: wsRows[0]?.createdAt || new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + + await syncWorkspaceEnvCredentials({ workspaceId, - variables: current, - createdAt: wsRows[0]?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, + envKeys: Object.keys(current), + actingUserId: userId, }) - await syncWorkspaceEnvCredentials({ - workspaceId, - envKeys: Object.keys(current), - actingUserId: userId, - }) - - return NextResponse.json({ success: true }) - } catch (error: any) { - logger.error(`[${requestId}] Workspace env DELETE error`, error) - return NextResponse.json( - { error: error.message || 'Failed to remove environment keys' }, - { status: 500 } - ) + return NextResponse.json({ success: true }) + } catch (error: any) { + logger.error(`[${requestId}] Workspace env DELETE error`, error) + return NextResponse.json( + { error: error.message || 'Failed to remove environment keys' }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 24b5eb56cf0..e26b4a1973e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -14,81 +15,84 @@ const logger = createLogger('WorkspaceFileContentAPI') * PUT /api/workspaces/[id]/files/[fileId]/content * Update a workspace file's text content (requires write permission) */ -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ id: string; fileId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const body = await request.json() - const { content } = body as { content: string } + const body = await request.json() + const { content } = body as { content: string } - if (typeof content !== 'string') { - return NextResponse.json({ error: 'Content must be a string' }, { status: 400 }) - } + if (typeof content !== 'string') { + return NextResponse.json({ error: 'Content must be a string' }, { status: 400 }) + } - const buffer = Buffer.from(content, 'utf-8') + const buffer = Buffer.from(content, 'utf-8') - const maxFileSizeBytes = 50 * 1024 * 1024 - if (buffer.length > maxFileSizeBytes) { - return NextResponse.json( - { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` }, - { status: 413 } + const maxFileSizeBytes = 50 * 1024 * 1024 + if (buffer.length > maxFileSizeBytes) { + return NextResponse.json( + { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` }, + { status: 413 } + ) + } + + const updatedFile = await updateWorkspaceFileContent( + workspaceId, + fileId, + session.user.id, + buffer ) - } - const updatedFile = await updateWorkspaceFileContent( - workspaceId, - fileId, - session.user.id, - buffer - ) + logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`) - logger.info(`[${requestId}] Updated content for workspace file: ${updatedFile.name}`) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + description: `Updated content of file "${updatedFile.name}"`, + request, + }) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - description: `Updated content of file "${updatedFile.name}"`, - request, - }) + return NextResponse.json({ + success: true, + file: updatedFile, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to update file content' + const isNotFound = errorMessage.includes('File not found') + const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') + const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 - return NextResponse.json({ - success: true, - file: updatedFile, - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to update file content' - const isNotFound = errorMessage.includes('File not found') - const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') - const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 + if (status === 500) { + logger.error(`[${requestId}] Error updating file content:`, error) + } else { + logger.warn(`[${requestId}] ${errorMessage}`) + } - if (status === 500) { - logger.error(`[${requestId}] Error updating file content:`, error) - } else { - logger.warn(`[${requestId}] ${errorMessage}`) + return NextResponse.json({ success: false, error: errorMessage }, { status }) } - - return NextResponse.json({ success: false, error: errorMessage }, { status }) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index ac87eb5811d..597d0290cfe 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -14,53 +15,52 @@ const logger = createLogger('WorkspaceFileDownloadAPI') * Return authenticated file serve URL (requires read permission) * Uses /api/files/serve endpoint which enforces authentication and context */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string; fileId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!userPermission) { + logger.warn( + `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } + const fileRecord = await getWorkspaceFile(workspaceId, fileId) + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } - const { getBaseUrl } = await import('@/lib/core/utils/urls') - const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` - const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}` + const { getBaseUrl } = await import('@/lib/core/utils/urls') + const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` + const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}` - logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) + logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) - return NextResponse.json({ - success: true, - downloadUrl: serveUrl, - viewerUrl: viewerUrl, - fileName: fileRecord.name, - expiresIn: null, - }) - } catch (error) { - logger.error(`[${requestId}] Error generating download URL:`, error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to generate download URL', - }, - { status: 500 } - ) + return NextResponse.json({ + success: true, + downloadUrl: serveUrl, + viewerUrl: viewerUrl, + fileName: fileRecord.name, + expiresIn: null, + }) + } catch (error) { + logger.error(`[${requestId}] Error generating download URL:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to generate download URL', + }, + { status: 500 } + ) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index 0fdff8c39f2..b932c53f267 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -3,55 +3,59 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { FileConflictError, restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('RestoreWorkspaceFileAPI') -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string; fileId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + if (userPermission !== 'admin' && userPermission !== 'write') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + await restoreWorkspaceFile(workspaceId, fileId) + + logger.info(`[${requestId}] Restored workspace file ${fileId}`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: fileId, + description: `Restored workspace file ${fileId}`, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof FileConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) } - - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - await restoreWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_RESTORED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileId, - description: `Restored workspace file ${fileId}`, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - if (error instanceof FileConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } - logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index c440618863e..a28d131a9ef 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteWorkspaceFile, FileConflictError, @@ -18,119 +19,125 @@ const logger = createLogger('WorkspaceFileAPI') * PATCH /api/workspaces/[id]/files/[fileId] * Rename a workspace file (requires write permission) */ -export async function PATCH( - request: NextRequest, - { params }: { params: Promise<{ id: string; fileId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const body = await request.json() + const { name } = body + + if (!name || typeof name !== 'string' || !name.trim()) { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }) + } + + const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name) + + logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + description: `Renamed file to "${updatedFile.name}"`, + request, + }) + + return NextResponse.json({ + success: true, + file: updatedFile, + }) + } catch (error) { + logger.error(`[${requestId}] Error renaming workspace file:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to rename file', + }, + { status: error instanceof FileConflictError ? 409 : 500 } ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const body = await request.json() - const { name } = body - - if (!name || typeof name !== 'string' || !name.trim()) { - return NextResponse.json({ error: 'Name is required' }, { status: 400 }) } - - const updatedFile = await renameWorkspaceFile(workspaceId, fileId, name) - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${updatedFile.name}"`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - description: `Renamed file to "${updatedFile.name}"`, - request, - }) - - return NextResponse.json({ - success: true, - file: updatedFile, - }) - } catch (error) { - logger.error(`[${requestId}] Error renaming workspace file:`, error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to rename file', - }, - { status: error instanceof FileConflictError ? 409 : 500 } - ) } -} +) /** * DELETE /api/workspaces/[id]/files/[fileId] * Archive a workspace file (requires write permission) */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string; fileId: string }> } -) { - const requestId = generateRequestId() - const { id: workspaceId, fileId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires write) - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId, fileId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check workspace permissions (requires write) + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + await deleteWorkspaceFile(workspaceId, fileId) + + logger.info(`[${requestId}] Archived workspace file: ${fileId}`) + + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + description: `Archived file "${fileId}"`, + request, + }) + + return NextResponse.json({ + success: true, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting workspace file:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete file', + }, + { status: 500 } ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - - await deleteWorkspaceFile(workspaceId, fileId) - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - description: `Archived file "${fileId}"`, - request, - }) - - return NextResponse.json({ - success: true, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting workspace file:`, error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to delete file', - }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 5c887442796..7e6d9e4fb93 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { FileConflictError, @@ -21,140 +22,151 @@ const logger = createLogger('WorkspaceFilesAPI') * GET /api/workspaces/[id]/files * List all files for a workspace (requires read permission) */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id: workspaceId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires read) - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check workspace permissions (requires read) + const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!userPermission) { + logger.warn( + `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const scope = (new URL(request.url).searchParams.get('scope') ?? + 'active') as WorkspaceFileScope + if (!['active', 'archived', 'all'].includes(scope)) { + return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + } + + const files = await listWorkspaceFiles(workspaceId, { scope }) + + logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) + + return NextResponse.json({ + success: true, + files, + }) + } catch (error) { + logger.error(`[${requestId}] Error listing workspace files:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to list files', + }, + { status: 500 } ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - - const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceFileScope - if (!['active', 'archived', 'all'].includes(scope)) { - return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) - } - - const files = await listWorkspaceFiles(workspaceId, { scope }) - - logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) - - return NextResponse.json({ - success: true, - files, - }) - } catch (error) { - logger.error(`[${requestId}] Error listing workspace files:`, error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to list files', - }, - { status: 500 } - ) } -} +) /** * POST /api/workspaces/[id]/files * Upload a new file to workspace storage (requires write permission) */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id: workspaceId } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires write) - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + const { id: workspaceId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check workspace permissions (requires write) + const userPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + if (userPermission !== 'admin' && userPermission !== 'write') { + logger.warn( + `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const formData = await request.formData() + const rawFile = formData.get('file') + + if (!rawFile || !(rawFile instanceof File)) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + const fileName = rawFile.name || 'untitled.md' + + const maxSize = 100 * 1024 * 1024 + if (rawFile.size > maxSize) { + return NextResponse.json( + { + error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)`, + }, + { status: 400 } + ) + } + + const buffer = Buffer.from(await rawFile.arrayBuffer()) + + const userFile = await uploadWorkspaceFile( + workspaceId, + session.user.id, + buffer, + fileName, + rawFile.type || 'application/octet-stream' ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - const formData = await request.formData() - const rawFile = formData.get('file') + logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) - if (!rawFile || !(rawFile instanceof File)) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) - } + captureServerEvent( + session.user.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' }, + { groups: { workspace: workspaceId } } + ) - const fileName = rawFile.name || 'untitled.md' + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: fileName, + description: `Uploaded file "${fileName}"`, + request, + }) + + return NextResponse.json({ + success: true, + file: userFile, + }) + } catch (error) { + logger.error(`[${requestId}] Error uploading workspace file:`, error) + + const errorMessage = error instanceof Error ? error.message : 'Failed to upload file' + const isDuplicate = + error instanceof FileConflictError || errorMessage.includes('already exists') - const maxSize = 100 * 1024 * 1024 - if (rawFile.size > maxSize) { return NextResponse.json( - { error: `File size exceeds 100MB limit (${(rawFile.size / (1024 * 1024)).toFixed(2)}MB)` }, - { status: 400 } + { + success: false, + error: errorMessage, + isDuplicate, + }, + { status: isDuplicate ? 409 : 500 } ) } - - const buffer = Buffer.from(await rawFile.arrayBuffer()) - - const userFile = await uploadWorkspaceFile( - workspaceId, - session.user.id, - buffer, - fileName, - rawFile.type || 'application/octet-stream' - ) - - logger.info(`[${requestId}] Uploaded workspace file: ${fileName}`) - - captureServerEvent( - session.user.id, - 'file_uploaded', - { workspace_id: workspaceId, file_type: rawFile.type || 'application/octet-stream' }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPLOADED, - resourceType: AuditResourceType.FILE, - resourceId: userFile.id, - resourceName: fileName, - description: `Uploaded file "${fileName}"`, - request, - }) - - return NextResponse.json({ - success: true, - file: userFile, - }) - } catch (error) { - logger.error(`[${requestId}] Error uploading workspace file:`, error) - - const errorMessage = error instanceof Error ? error.message : 'Failed to upload file' - const isDuplicate = - error instanceof FileConflictError || errorMessage.includes('already exists') - - return NextResponse.json( - { - success: false, - error: errorMessage, - isDuplicate, - }, - { status: isDuplicate ? 409 : 500 } - ) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 3e64cf34174..34cdc7e8037 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { hasInboxAccess } from '@/lib/billing/core/subscription' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -15,126 +16,133 @@ const patchSchema = z.object({ username: z.string().min(1).max(64).optional(), }) -export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteHandler( + async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [hasAccess, permission] = await Promise.all([ - hasInboxAccess(session.user.id), - getUserEntityPermissions(session.user.id, 'workspace', workspaceId), - ]) - if (!hasAccess) { - return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) - } - if (!permission) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) + if (!hasAccess) { + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) + } + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } - const [wsResult, statsResult] = await Promise.all([ - db - .select({ - inboxEnabled: workspace.inboxEnabled, - inboxAddress: workspace.inboxAddress, - }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1), - db - .select({ - status: mothershipInboxTask.status, - count: sql`count(*)::int`, - }) - .from(mothershipInboxTask) - .where(eq(mothershipInboxTask.workspaceId, workspaceId)) - .groupBy(mothershipInboxTask.status), - ]) - - const [ws] = wsResult - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + const [wsResult, statsResult] = await Promise.all([ + db + .select({ + inboxEnabled: workspace.inboxEnabled, + inboxAddress: workspace.inboxAddress, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1), + db + .select({ + status: mothershipInboxTask.status, + count: sql`count(*)::int`, + }) + .from(mothershipInboxTask) + .where(eq(mothershipInboxTask.workspaceId, workspaceId)) + .groupBy(mothershipInboxTask.status), + ]) + + const [ws] = wsResult + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - const stats = { - total: 0, - completed: 0, - processing: 0, - failed: 0, - } - for (const row of statsResult) { - const count = Number(row.count) - stats.total += count - if (row.status === 'completed') stats.completed = count - else if (row.status === 'processing') stats.processing = count - else if (row.status === 'failed') stats.failed = count - } + const stats = { + total: 0, + completed: 0, + processing: 0, + failed: 0, + } + for (const row of statsResult) { + const count = Number(row.count) + stats.total += count + if (row.status === 'completed') stats.completed = count + else if (row.status === 'processing') stats.processing = count + else if (row.status === 'failed') stats.failed = count + } - return NextResponse.json({ - enabled: ws.inboxEnabled, - address: ws.inboxAddress, - taskStats: stats, - }) -} - -export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ + enabled: ws.inboxEnabled, + address: ws.inboxAddress, + taskStats: stats, + }) } +) + +export const PATCH = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [hasAccess, permission] = await Promise.all([ - hasInboxAccess(session.user.id), - getUserEntityPermissions(session.user.id, 'workspace', workspaceId), - ]) - if (!hasAccess) { - return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) - } - if (permission !== 'admin') { - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) + if (!hasAccess) { + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) + } + if (permission !== 'admin') { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } - try { - const body = patchSchema.parse(await req.json()) + try { + const body = patchSchema.parse(await req.json()) + + if (body.enabled === true) { + const [current] = await db + .select({ inboxEnabled: workspace.inboxEnabled }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + if (current?.inboxEnabled) { + return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 }) + } + const config = await enableInbox(workspaceId, { username: body.username }) + return NextResponse.json(config) + } - if (body.enabled === true) { - const [current] = await db - .select({ inboxEnabled: workspace.inboxEnabled }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - if (current?.inboxEnabled) { - return NextResponse.json({ error: 'Inbox is already enabled' }, { status: 409 }) + if (body.enabled === false) { + await disableInbox(workspaceId) + return NextResponse.json({ enabled: false, address: null }) } - const config = await enableInbox(workspaceId, { username: body.username }) - return NextResponse.json(config) - } - if (body.enabled === false) { - await disableInbox(workspaceId) - return NextResponse.json({ enabled: false, address: null }) - } + if (body.username) { + const config = await updateInboxAddress(workspaceId, body.username) + return NextResponse.json(config) + } - if (body.username) { - const config = await updateInboxAddress(workspaceId, body.username) - return NextResponse.json(config) - } + return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request', details: error.errors }, + { status: 400 } + ) + } - return NextResponse.json({ error: 'No valid update provided' }, { status: 400 }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) + logger.error('Inbox config update failed', { + workspaceId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update inbox' }, + { status: 500 } + ) } - - logger.error('Inbox config update failed', { - workspaceId, - error: error instanceof Error ? error.message : 'Unknown error', - }) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to update inbox' }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts index 488f819a48f..25ca0cb2def 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { hasInboxAccess } from '@/lib/billing/core/subscription' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxSendersAPI') @@ -19,154 +20,166 @@ const deleteSenderSchema = z.object({ senderId: z.string().min(1), }) -export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const [hasAccess, permission] = await Promise.all([ - hasInboxAccess(session.user.id), - getUserEntityPermissions(session.user.id, 'workspace', workspaceId), - ]) - if (!hasAccess) { - return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) - } - if (!permission) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } +export const GET = withRouteHandler( + async (_req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [senders, members] = await Promise.all([ - db - .select({ - id: mothershipInboxAllowedSender.id, - email: mothershipInboxAllowedSender.email, - label: mothershipInboxAllowedSender.label, - createdAt: mothershipInboxAllowedSender.createdAt, - }) - .from(mothershipInboxAllowedSender) - .where(eq(mothershipInboxAllowedSender.workspaceId, workspaceId)) - .orderBy(mothershipInboxAllowedSender.createdAt), - db - .select({ - email: user.email, - name: user.name, - }) - .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), - ]) - - return NextResponse.json({ - senders: senders.map((s) => ({ - id: s.id, - email: s.email, - label: s.label, - createdAt: s.createdAt, - })), - workspaceMembers: members.map((m) => ({ - email: m.email, - name: m.name, - isAutoAllowed: true, - })), - }) -} - -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) + if (!hasAccess) { + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) + } + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } - const [hasAccess, permission] = await Promise.all([ - hasInboxAccess(session.user.id), - getUserEntityPermissions(session.user.id, 'workspace', workspaceId), - ]) - if (!hasAccess) { - return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) - } - if (permission !== 'admin') { - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + const [senders, members] = await Promise.all([ + db + .select({ + id: mothershipInboxAllowedSender.id, + email: mothershipInboxAllowedSender.email, + label: mothershipInboxAllowedSender.label, + createdAt: mothershipInboxAllowedSender.createdAt, + }) + .from(mothershipInboxAllowedSender) + .where(eq(mothershipInboxAllowedSender.workspaceId, workspaceId)) + .orderBy(mothershipInboxAllowedSender.createdAt), + db + .select({ + email: user.email, + name: user.name, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))), + ]) + + return NextResponse.json({ + senders: senders.map((s) => ({ + id: s.id, + email: s.email, + label: s.label, + createdAt: s.createdAt, + })), + workspaceMembers: members.map((m) => ({ + email: m.email, + name: m.name, + isAutoAllowed: true, + })), + }) } - - try { - const { email, label } = addSenderSchema.parse(await req.json()) - const normalizedEmail = email.toLowerCase() - - const [existing] = await db - .select({ id: mothershipInboxAllowedSender.id }) - .from(mothershipInboxAllowedSender) - .where( - and( - eq(mothershipInboxAllowedSender.workspaceId, workspaceId), - eq(mothershipInboxAllowedSender.email, normalizedEmail) - ) - ) - .limit(1) - - if (existing) { - return NextResponse.json({ error: 'Sender already exists' }, { status: 409 }) +) + +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const [sender] = await db - .insert(mothershipInboxAllowedSender) - .values({ - id: generateId(), - workspaceId, - email: normalizedEmail, - label: label || null, - addedBy: session.user.id, - }) - .returning() - - return NextResponse.json({ sender }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) + if (!hasAccess) { + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) + } + if (permission !== 'admin') { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) } - logger.error('Failed to add sender', { workspaceId, error }) - return NextResponse.json({ error: 'Failed to add sender' }, { status: 500 }) - } -} -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + try { + const { email, label } = addSenderSchema.parse(await req.json()) + const normalizedEmail = email.toLowerCase() + + const [existing] = await db + .select({ id: mothershipInboxAllowedSender.id }) + .from(mothershipInboxAllowedSender) + .where( + and( + eq(mothershipInboxAllowedSender.workspaceId, workspaceId), + eq(mothershipInboxAllowedSender.email, normalizedEmail) + ) + ) + .limit(1) + + if (existing) { + return NextResponse.json({ error: 'Sender already exists' }, { status: 409 }) + } + + const [sender] = await db + .insert(mothershipInboxAllowedSender) + .values({ + id: generateId(), + workspaceId, + email: normalizedEmail, + label: label || null, + addedBy: session.user.id, + }) + .returning() + + return NextResponse.json({ sender }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request', details: error.errors }, + { status: 400 } + ) + } + logger.error('Failed to add sender', { workspaceId, error }) + return NextResponse.json({ error: 'Failed to add sender' }, { status: 500 }) + } } +) + +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [hasAccess, permission] = await Promise.all([ - hasInboxAccess(session.user.id), - getUserEntityPermissions(session.user.id, 'workspace', workspaceId), - ]) - if (!hasAccess) { - return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) - } - if (permission !== 'admin') { - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) + if (!hasAccess) { + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) + } + if (permission !== 'admin') { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) + } - try { - const { senderId } = deleteSenderSchema.parse(await req.json()) + try { + const { senderId } = deleteSenderSchema.parse(await req.json()) - await db - .delete(mothershipInboxAllowedSender) - .where( - and( - eq(mothershipInboxAllowedSender.id, senderId), - eq(mothershipInboxAllowedSender.workspaceId, workspaceId) + await db + .delete(mothershipInboxAllowedSender) + .where( + and( + eq(mothershipInboxAllowedSender.id, senderId), + eq(mothershipInboxAllowedSender.workspaceId, workspaceId) + ) ) - ) - return NextResponse.json({ ok: true }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json({ error: 'Invalid request', details: error.errors }, { status: 400 }) + return NextResponse.json({ ok: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request', details: error.errors }, + { status: 400 } + ) + } + logger.error('Failed to delete sender', { workspaceId, error }) + return NextResponse.json({ error: 'Failed to delete sender' }, { status: 500 }) } - logger.error('Failed to delete sender', { workspaceId, error }) - return NextResponse.json({ error: 'Failed to delete sender' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts index 8deb40cb670..77b536e2215 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts @@ -4,85 +4,88 @@ import { and, desc, eq, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { hasInboxAccess } from '@/lib/billing/core/subscription' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxTasksAPI') -export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const [hasAccess, permission] = await Promise.all([ - hasInboxAccess(session.user.id), - getUserEntityPermissions(session.user.id, 'workspace', workspaceId), - ]) - if (!hasAccess) { - return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) - } - if (!permission) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } + const [hasAccess, permission] = await Promise.all([ + hasInboxAccess(session.user.id), + getUserEntityPermissions(session.user.id, 'workspace', workspaceId), + ]) + if (!hasAccess) { + return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 }) + } + if (!permission) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } - const url = new URL(req.url) - const status = url.searchParams.get('status') || 'all' - const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50) - const cursor = url.searchParams.get('cursor') // ISO date string for cursor-based pagination + const url = new URL(req.url) + const status = url.searchParams.get('status') || 'all' + const limit = Math.min(Number(url.searchParams.get('limit') || '20'), 50) + const cursor = url.searchParams.get('cursor') // ISO date string for cursor-based pagination - const conditions = [eq(mothershipInboxTask.workspaceId, workspaceId)] + const conditions = [eq(mothershipInboxTask.workspaceId, workspaceId)] - const validStatuses = ['received', 'processing', 'completed', 'failed', 'rejected'] as const - if (status !== 'all') { - if (!validStatuses.includes(status as (typeof validStatuses)[number])) { - return NextResponse.json({ error: 'Invalid status filter' }, { status: 400 }) + const validStatuses = ['received', 'processing', 'completed', 'failed', 'rejected'] as const + if (status !== 'all') { + if (!validStatuses.includes(status as (typeof validStatuses)[number])) { + return NextResponse.json({ error: 'Invalid status filter' }, { status: 400 }) + } + conditions.push(eq(mothershipInboxTask.status, status)) } - conditions.push(eq(mothershipInboxTask.status, status)) - } - if (cursor) { - const cursorDate = new Date(cursor) - if (Number.isNaN(cursorDate.getTime())) { - return NextResponse.json({ error: 'Invalid cursor value' }, { status: 400 }) + if (cursor) { + const cursorDate = new Date(cursor) + if (Number.isNaN(cursorDate.getTime())) { + return NextResponse.json({ error: 'Invalid cursor value' }, { status: 400 }) + } + conditions.push(lt(mothershipInboxTask.createdAt, cursorDate)) } - conditions.push(lt(mothershipInboxTask.createdAt, cursorDate)) - } - const tasks = await db - .select({ - id: mothershipInboxTask.id, - fromEmail: mothershipInboxTask.fromEmail, - fromName: mothershipInboxTask.fromName, - subject: mothershipInboxTask.subject, - bodyPreview: mothershipInboxTask.bodyPreview, - status: mothershipInboxTask.status, - hasAttachments: mothershipInboxTask.hasAttachments, - resultSummary: mothershipInboxTask.resultSummary, - errorMessage: mothershipInboxTask.errorMessage, - rejectionReason: mothershipInboxTask.rejectionReason, - chatId: mothershipInboxTask.chatId, - createdAt: mothershipInboxTask.createdAt, - completedAt: mothershipInboxTask.completedAt, - }) - .from(mothershipInboxTask) - .where(and(...conditions)) - .orderBy(desc(mothershipInboxTask.createdAt)) - .limit(limit + 1) // Fetch one extra to determine hasMore + const tasks = await db + .select({ + id: mothershipInboxTask.id, + fromEmail: mothershipInboxTask.fromEmail, + fromName: mothershipInboxTask.fromName, + subject: mothershipInboxTask.subject, + bodyPreview: mothershipInboxTask.bodyPreview, + status: mothershipInboxTask.status, + hasAttachments: mothershipInboxTask.hasAttachments, + resultSummary: mothershipInboxTask.resultSummary, + errorMessage: mothershipInboxTask.errorMessage, + rejectionReason: mothershipInboxTask.rejectionReason, + chatId: mothershipInboxTask.chatId, + createdAt: mothershipInboxTask.createdAt, + completedAt: mothershipInboxTask.completedAt, + }) + .from(mothershipInboxTask) + .where(and(...conditions)) + .orderBy(desc(mothershipInboxTask.createdAt)) + .limit(limit + 1) // Fetch one extra to determine hasMore - const hasMore = tasks.length > limit - const resultTasks = hasMore ? tasks.slice(0, limit) : tasks - const nextCursor = - hasMore && resultTasks.length > 0 - ? resultTasks[resultTasks.length - 1].createdAt.toISOString() - : null + const hasMore = tasks.length > limit + const resultTasks = hasMore ? tasks.slice(0, limit) : tasks + const nextCursor = + hasMore && resultTasks.length > 0 + ? resultTasks[resultTasks.length - 1].createdAt.toISOString() + : null - return NextResponse.json({ - tasks: resultTasks, - pagination: { - limit, - hasMore, - nextCursor, - }, - }) -} + return NextResponse.json({ + tasks: resultTasks, + pagination: { + limit, + hasMore, + nextCursor, + }, + }) + } +) diff --git a/apps/sim/app/api/workspaces/[id]/members/route.ts b/apps/sim/app/api/workspaces/[id]/members/route.ts index 987946d5a3c..8a46593570c 100644 --- a/apps/sim/app/api/workspaces/[id]/members/route.ts +++ b/apps/sim/app/api/workspaces/[id]/members/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions, getWorkspaceMemberProfiles, @@ -15,25 +16,27 @@ const logger = createLogger('WorkspaceMembersAPI') * Intended for UI display (avatars, owner cells) without the overhead of * full permission data. */ -export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id: workspaceId } = await params - const session = await getSession() +export const GET = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const { id: workspaceId } = await params + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission === null) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission === null) { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) + } - const members = await getWorkspaceMemberProfiles(workspaceId) + const members = await getWorkspaceMemberProfiles(workspaceId) - return NextResponse.json({ members }) - } catch (error) { - logger.error('Error fetching workspace members:', error) - return NextResponse.json({ error: 'Failed to fetch workspace members' }, { status: 500 }) + return NextResponse.json({ members }) + } catch (error) { + logger.error('Error fetching workspace members:', error) + return NextResponse.json({ error: 'Failed to fetch workspace members' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts index 384d7edb040..506688d27c9 100644 --- a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts @@ -5,6 +5,7 @@ import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('MetricsExecutionsAPI') @@ -22,250 +23,252 @@ const QueryParamsSchema = z.object({ .transform((v) => v === 'true'), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id: workspaceId } = await params - const { searchParams } = new URL(request.url) - const qp = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const { id: workspaceId } = await params + const { searchParams } = new URL(request.url) + const qp = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id - let end = qp.endTime ? new Date(qp.endTime) : new Date() - let start = qp.startTime - ? new Date(qp.startTime) - : new Date(end.getTime() - 24 * 60 * 60 * 1000) + let end = qp.endTime ? new Date(qp.endTime) : new Date() + let start = qp.startTime + ? new Date(qp.startTime) + : new Date(end.getTime() - 24 * 60 * 60 * 1000) - const isAllTime = qp.allTime === true + const isAllTime = qp.allTime === true - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) - } + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) + } - const segments = qp.segments + const segments = qp.segments - const [permission] = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, userId) + const [permission] = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.userId, userId) + ) ) - ) - .limit(1) - if (!permission) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - const wfWhere = [eq(workflow.workspaceId, workspaceId)] as any[] - if (qp.folderIds) { - const folderList = qp.folderIds.split(',').filter(Boolean) - wfWhere.push(inArray(workflow.folderId, folderList)) - } - if (qp.workflowIds) { - const wfList = qp.workflowIds.split(',').filter(Boolean) - wfWhere.push(inArray(workflow.id, wfList)) - } + .limit(1) + if (!permission) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const wfWhere = [eq(workflow.workspaceId, workspaceId)] as any[] + if (qp.folderIds) { + const folderList = qp.folderIds.split(',').filter(Boolean) + wfWhere.push(inArray(workflow.folderId, folderList)) + } + if (qp.workflowIds) { + const wfList = qp.workflowIds.split(',').filter(Boolean) + wfWhere.push(inArray(workflow.id, wfList)) + } - const workflows = await db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(and(...wfWhere)) + const workflows = await db + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where(and(...wfWhere)) - if (workflows.length === 0) { - return NextResponse.json({ - workflows: [], - startTime: start.toISOString(), - endTime: end.toISOString(), - segmentMs: 0, - }) - } + if (workflows.length === 0) { + return NextResponse.json({ + workflows: [], + startTime: start.toISOString(), + endTime: end.toISOString(), + segmentMs: 0, + }) + } - const workflowIdList = workflows.map((w) => w.id) + const workflowIdList = workflows.map((w) => w.id) - const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[] - if (qp.triggers) { - const t = qp.triggers.split(',').filter(Boolean) - baseLogWhere.push(inArray(workflowExecutionLogs.trigger, t)) - } + const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[] + if (qp.triggers) { + const t = qp.triggers.split(',').filter(Boolean) + baseLogWhere.push(inArray(workflowExecutionLogs.trigger, t)) + } - if (qp.level && qp.level !== 'all') { - const levels = qp.level.split(',').filter(Boolean) - const levelConditions: SQL[] = [] + if (qp.level && qp.level !== 'all') { + const levels = qp.level.split(',').filter(Boolean) + const levelConditions: SQL[] = [] - for (const level of levels) { - if (level === 'error') { - levelConditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (level === 'info') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNotNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'running') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'pending') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - or( - sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, - and( - isNotNull(pausedExecutions.status), - sql`${pausedExecutions.status} != 'fully_resumed'` + for (const level of levels) { + if (level === 'error') { + levelConditions.push(eq(workflowExecutionLogs.level, 'error')) + } else if (level === 'info') { + const condition = and( + eq(workflowExecutionLogs.level, 'info'), + isNotNull(workflowExecutionLogs.endedAt) + ) + if (condition) levelConditions.push(condition) + } else if (level === 'running') { + const condition = and( + eq(workflowExecutionLogs.level, 'info'), + isNull(workflowExecutionLogs.endedAt) + ) + if (condition) levelConditions.push(condition) + } else if (level === 'pending') { + const condition = and( + eq(workflowExecutionLogs.level, 'info'), + or( + sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, + and( + isNotNull(pausedExecutions.status), + sql`${pausedExecutions.status} != 'fully_resumed'` + ) ) ) + if (condition) levelConditions.push(condition) + } + } + + if (levelConditions.length > 0) { + const combinedCondition = + levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) + if (combinedCondition) baseLogWhere.push(combinedCondition) + } + } + + if (isAllTime) { + const boundsQuery = db + .select({ + minDate: sql`MIN(${workflowExecutionLogs.startedAt})`, + maxDate: sql`MAX(${workflowExecutionLogs.startedAt})`, + }) + .from(workflowExecutionLogs) + .leftJoin( + pausedExecutions, + eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) ) - if (condition) levelConditions.push(condition) + .where(and(...baseLogWhere)) + + const [bounds] = await boundsQuery + + if (bounds?.minDate && bounds?.maxDate) { + start = new Date(bounds.minDate) + end = new Date(Math.max(new Date(bounds.maxDate).getTime(), Date.now())) + } else { + return NextResponse.json({ + workflows: workflows.map((wf) => ({ + workflowId: wf.id, + workflowName: wf.name, + segments: [], + })), + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + segmentMs: 0, + }) } } - if (levelConditions.length > 0) { - const combinedCondition = - levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) - if (combinedCondition) baseLogWhere.push(combinedCondition) + if (start >= end) { + return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) } - } - if (isAllTime) { - const boundsQuery = db + const totalMs = Math.max(1, end.getTime() - start.getTime()) + const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments))) + + const logWhere = [ + ...baseLogWhere, + gte(workflowExecutionLogs.startedAt, start), + lte(workflowExecutionLogs.startedAt, end), + ] + + const logs = await db .select({ - minDate: sql`MIN(${workflowExecutionLogs.startedAt})`, - maxDate: sql`MAX(${workflowExecutionLogs.startedAt})`, + workflowId: workflowExecutionLogs.workflowId, + level: workflowExecutionLogs.level, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + pausedTotalPauseCount: pausedExecutions.totalPauseCount, + pausedResumedCount: pausedExecutions.resumedCount, + pausedStatus: pausedExecutions.status, }) .from(workflowExecutionLogs) .leftJoin( pausedExecutions, eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) ) - .where(and(...baseLogWhere)) - - const [bounds] = await boundsQuery + .where(and(...logWhere)) - if (bounds?.minDate && bounds?.maxDate) { - start = new Date(bounds.minDate) - end = new Date(Math.max(new Date(bounds.maxDate).getTime(), Date.now())) - } else { - return NextResponse.json({ - workflows: workflows.map((wf) => ({ - workflowId: wf.id, - workflowName: wf.name, - segments: [], - })), - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - segmentMs: 0, - }) + type Bucket = { + timestamp: string + totalExecutions: number + successfulExecutions: number + durations: number[] } - } - if (start >= end) { - return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) - } + const wfIdToBuckets = new Map() + for (const wf of workflows) { + const buckets: Bucket[] = Array.from({ length: segments }, (_, i) => ({ + timestamp: new Date(start.getTime() + i * segmentMs).toISOString(), + totalExecutions: 0, + successfulExecutions: 0, + durations: [], + })) + wfIdToBuckets.set(wf.id, buckets) + } - const totalMs = Math.max(1, end.getTime() - start.getTime()) - const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments))) + for (const log of logs) { + if (!log.workflowId) continue // Skip logs for deleted workflows + const idx = Math.min( + segments - 1, + Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs)) + ) + const buckets = wfIdToBuckets.get(log.workflowId) + if (!buckets) continue + const b = buckets[idx] + b.totalExecutions += 1 + if ((log.level || '').toLowerCase() !== 'error') b.successfulExecutions += 1 + if (typeof log.totalDurationMs === 'number') b.durations.push(log.totalDurationMs) + } - const logWhere = [ - ...baseLogWhere, - gte(workflowExecutionLogs.startedAt, start), - lte(workflowExecutionLogs.startedAt, end), - ] + function percentile(arr: number[], p: number): number { + if (arr.length === 0) return 0 + const sorted = [...arr].sort((a, b) => a - b) + const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * (sorted.length - 1))) + return sorted[idx] + } - const logs = await db - .select({ - workflowId: workflowExecutionLogs.workflowId, - level: workflowExecutionLogs.level, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - pausedStatus: pausedExecutions.status, + const result = workflows.map((wf) => { + const buckets = wfIdToBuckets.get(wf.id) as Bucket[] + const segmentsOut = buckets.map((b) => { + const avg = + b.durations.length > 0 + ? Math.round(b.durations.reduce((s, d) => s + d, 0) / b.durations.length) + : 0 + const p50 = percentile(b.durations, 50) + const p90 = percentile(b.durations, 90) + const p99 = percentile(b.durations, 99) + return { + timestamp: b.timestamp, + totalExecutions: b.totalExecutions, + successfulExecutions: b.successfulExecutions, + avgDurationMs: avg, + p50Ms: p50, + p90Ms: p90, + p99Ms: p99, + } + }) + return { workflowId: wf.id, workflowName: wf.name, segments: segmentsOut } }) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .where(and(...logWhere)) - - type Bucket = { - timestamp: string - totalExecutions: number - successfulExecutions: number - durations: number[] - } - - const wfIdToBuckets = new Map() - for (const wf of workflows) { - const buckets: Bucket[] = Array.from({ length: segments }, (_, i) => ({ - timestamp: new Date(start.getTime() + i * segmentMs).toISOString(), - totalExecutions: 0, - successfulExecutions: 0, - durations: [], - })) - wfIdToBuckets.set(wf.id, buckets) - } - - for (const log of logs) { - if (!log.workflowId) continue // Skip logs for deleted workflows - const idx = Math.min( - segments - 1, - Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs)) - ) - const buckets = wfIdToBuckets.get(log.workflowId) - if (!buckets) continue - const b = buckets[idx] - b.totalExecutions += 1 - if ((log.level || '').toLowerCase() !== 'error') b.successfulExecutions += 1 - if (typeof log.totalDurationMs === 'number') b.durations.push(log.totalDurationMs) - } - function percentile(arr: number[], p: number): number { - if (arr.length === 0) return 0 - const sorted = [...arr].sort((a, b) => a - b) - const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * (sorted.length - 1))) - return sorted[idx] - } - - const result = workflows.map((wf) => { - const buckets = wfIdToBuckets.get(wf.id) as Bucket[] - const segmentsOut = buckets.map((b) => { - const avg = - b.durations.length > 0 - ? Math.round(b.durations.reduce((s, d) => s + d, 0) / b.durations.length) - : 0 - const p50 = percentile(b.durations, 50) - const p90 = percentile(b.durations, 90) - const p99 = percentile(b.durations, 99) - return { - timestamp: b.timestamp, - totalExecutions: b.totalExecutions, - successfulExecutions: b.successfulExecutions, - avgDurationMs: avg, - p50Ms: p50, - p90Ms: p90, - p99Ms: p99, - } + return NextResponse.json({ + workflows: result, + startTime: start.toISOString(), + endTime: end.toISOString(), + segmentMs, }) - return { workflowId: wf.id, workflowName: wf.name, segments: segmentsOut } - }) - - return NextResponse.json({ - workflows: result, - startTime: start.toISOString(), - endTime: end.toISOString(), - segmentMs, - }) - } catch (error) { - logger.error('MetricsExecutionsAPI error', error) - return NextResponse.json({ error: 'Failed to compute metrics' }, { status: 500 }) + } catch (error) { + logger.error('MetricsExecutionsAPI error', error) + return NextResponse.json({ error: 'Failed to compute metrics' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 08d3f5802d2..99dea3000ed 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants' @@ -119,7 +120,7 @@ async function getSubscription(notificationId: string, workspaceId: string) { return subscription } -export async function GET(request: NextRequest, { params }: RouteParams) { +export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { try { const session = await getSession() if (!session?.user?.id) { @@ -164,9 +165,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) { logger.error('Error fetching notification', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function PUT(request: NextRequest, { params }: RouteParams) { +export const PUT = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { try { const session = await getSession() if (!session?.user?.id) { @@ -290,9 +291,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { logger.error('Error updating notification', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) -export async function DELETE(request: NextRequest, { params }: RouteParams) { +export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { try { const session = await getSession() if (!session?.user?.id) { @@ -359,4 +360,4 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { logger.error('Error deleting notification', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts index c1d0c930339..4b786462374 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -14,6 +14,7 @@ import { decryptSecret } from '@/lib/core/security/encryption' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -279,7 +280,7 @@ async function testSlack( } } -export async function POST(request: NextRequest, { params }: RouteParams) { +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { try { const session = await getSession() if (!session?.user?.id) { @@ -336,4 +337,4 @@ export async function POST(request: NextRequest, { params }: RouteParams) { logger.error('Error testing notification', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index 1a18f8d2386..808da8a4370 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants' @@ -116,194 +117,198 @@ async function checkWorkspaceWriteAccess( return { hasAccess, permission } } -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = 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: workspaceId } = await params - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + const { id: workspaceId } = await params + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - const subscriptions = await db - .select({ - id: workspaceNotificationSubscription.id, - notificationType: workspaceNotificationSubscription.notificationType, - workflowIds: workspaceNotificationSubscription.workflowIds, - allWorkflows: workspaceNotificationSubscription.allWorkflows, - levelFilter: workspaceNotificationSubscription.levelFilter, - triggerFilter: workspaceNotificationSubscription.triggerFilter, - includeFinalOutput: workspaceNotificationSubscription.includeFinalOutput, - includeTraceSpans: workspaceNotificationSubscription.includeTraceSpans, - includeRateLimits: workspaceNotificationSubscription.includeRateLimits, - includeUsageData: workspaceNotificationSubscription.includeUsageData, - webhookConfig: workspaceNotificationSubscription.webhookConfig, - emailRecipients: workspaceNotificationSubscription.emailRecipients, - slackConfig: workspaceNotificationSubscription.slackConfig, - alertConfig: workspaceNotificationSubscription.alertConfig, - active: workspaceNotificationSubscription.active, - createdAt: workspaceNotificationSubscription.createdAt, - updatedAt: workspaceNotificationSubscription.updatedAt, - }) - .from(workspaceNotificationSubscription) - .where(eq(workspaceNotificationSubscription.workspaceId, workspaceId)) - .orderBy(workspaceNotificationSubscription.createdAt) + const subscriptions = await db + .select({ + id: workspaceNotificationSubscription.id, + notificationType: workspaceNotificationSubscription.notificationType, + workflowIds: workspaceNotificationSubscription.workflowIds, + allWorkflows: workspaceNotificationSubscription.allWorkflows, + levelFilter: workspaceNotificationSubscription.levelFilter, + triggerFilter: workspaceNotificationSubscription.triggerFilter, + includeFinalOutput: workspaceNotificationSubscription.includeFinalOutput, + includeTraceSpans: workspaceNotificationSubscription.includeTraceSpans, + includeRateLimits: workspaceNotificationSubscription.includeRateLimits, + includeUsageData: workspaceNotificationSubscription.includeUsageData, + webhookConfig: workspaceNotificationSubscription.webhookConfig, + emailRecipients: workspaceNotificationSubscription.emailRecipients, + slackConfig: workspaceNotificationSubscription.slackConfig, + alertConfig: workspaceNotificationSubscription.alertConfig, + active: workspaceNotificationSubscription.active, + createdAt: workspaceNotificationSubscription.createdAt, + updatedAt: workspaceNotificationSubscription.updatedAt, + }) + .from(workspaceNotificationSubscription) + .where(eq(workspaceNotificationSubscription.workspaceId, workspaceId)) + .orderBy(workspaceNotificationSubscription.createdAt) - return NextResponse.json({ data: subscriptions }) - } catch (error) { - logger.error('Error fetching notifications', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ data: subscriptions }) + } catch (error) { + logger.error('Error fetching notifications', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + } +) - const { id: workspaceId } = await params - const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) - - if (!hasAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } +export const POST = 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 body = await request.json() - const validationResult = createNotificationSchema.safeParse(body) + const { id: workspaceId } = await params + const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, - { status: 400 } - ) - } + if (!hasAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const data = validationResult.data + const body = await request.json() + const validationResult = createNotificationSchema.safeParse(body) - const existingCount = await db - .select({ id: workspaceNotificationSubscription.id }) - .from(workspaceNotificationSubscription) - .where( - and( - eq(workspaceNotificationSubscription.workspaceId, workspaceId), - eq(workspaceNotificationSubscription.notificationType, data.notificationType) + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid request', details: validationResult.error.errors }, + { status: 400 } ) - ) - - if (existingCount.length >= MAX_NOTIFICATIONS_PER_TYPE) { - return NextResponse.json( - { - error: `Maximum ${MAX_NOTIFICATIONS_PER_TYPE} ${data.notificationType} notifications per workspace`, - }, - { status: 400 } - ) - } + } - if (!data.allWorkflows && data.workflowIds.length > 0) { - const workflowsInWorkspace = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), inArray(workflow.id, data.workflowIds))) + const data = validationResult.data - const validIds = new Set(workflowsInWorkspace.map((w) => w.id)) - const invalidIds = data.workflowIds.filter((id) => !validIds.has(id)) + const existingCount = await db + .select({ id: workspaceNotificationSubscription.id }) + .from(workspaceNotificationSubscription) + .where( + and( + eq(workspaceNotificationSubscription.workspaceId, workspaceId), + eq(workspaceNotificationSubscription.notificationType, data.notificationType) + ) + ) - if (invalidIds.length > 0) { + if (existingCount.length >= MAX_NOTIFICATIONS_PER_TYPE) { return NextResponse.json( - { error: 'Some workflow IDs do not belong to this workspace', invalidIds }, + { + error: `Maximum ${MAX_NOTIFICATIONS_PER_TYPE} ${data.notificationType} notifications per workspace`, + }, { status: 400 } ) } - } - let webhookConfig = data.webhookConfig || null - if (webhookConfig?.secret) { - const { encrypted } = await encryptSecret(webhookConfig.secret) - webhookConfig = { ...webhookConfig, secret: encrypted } - } + if (!data.allWorkflows && data.workflowIds.length > 0) { + const workflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), inArray(workflow.id, data.workflowIds))) + + const validIds = new Set(workflowsInWorkspace.map((w) => w.id)) + const invalidIds = data.workflowIds.filter((id) => !validIds.has(id)) + + if (invalidIds.length > 0) { + return NextResponse.json( + { error: 'Some workflow IDs do not belong to this workspace', invalidIds }, + { status: 400 } + ) + } + } + + let webhookConfig = data.webhookConfig || null + if (webhookConfig?.secret) { + const { encrypted } = await encryptSecret(webhookConfig.secret) + webhookConfig = { ...webhookConfig, secret: encrypted } + } + + const [subscription] = await db + .insert(workspaceNotificationSubscription) + .values({ + id: generateId(), + workspaceId, + notificationType: data.notificationType, + workflowIds: data.workflowIds, + allWorkflows: data.allWorkflows, + levelFilter: data.levelFilter, + triggerFilter: data.triggerFilter, + includeFinalOutput: data.includeFinalOutput, + includeTraceSpans: data.includeTraceSpans, + includeRateLimits: data.includeRateLimits, + includeUsageData: data.includeUsageData, + alertConfig: data.alertConfig || null, + webhookConfig, + emailRecipients: data.emailRecipients || null, + slackConfig: data.slackConfig || null, + createdBy: session.user.id, + }) + .returning() - const [subscription] = await db - .insert(workspaceNotificationSubscription) - .values({ - id: generateId(), + logger.info('Created notification subscription', { workspaceId, - notificationType: data.notificationType, - workflowIds: data.workflowIds, - allWorkflows: data.allWorkflows, - levelFilter: data.levelFilter, - triggerFilter: data.triggerFilter, - includeFinalOutput: data.includeFinalOutput, - includeTraceSpans: data.includeTraceSpans, - includeRateLimits: data.includeRateLimits, - includeUsageData: data.includeUsageData, - alertConfig: data.alertConfig || null, - webhookConfig, - emailRecipients: data.emailRecipients || null, - slackConfig: data.slackConfig || null, - createdBy: session.user.id, + subscriptionId: subscription.id, + type: data.notificationType, }) - .returning() - logger.info('Created notification subscription', { - workspaceId, - subscriptionId: subscription.id, - type: data.notificationType, - }) - - captureServerEvent( - session.user.id, - 'notification_channel_created', - { - workspace_id: workspaceId, - notification_type: data.notificationType, - alert_rule: data.alertConfig?.rule ?? null, - }, - { groups: { workspace: workspaceId } } - ) + captureServerEvent( + session.user.id, + 'notification_channel_created', + { + workspace_id: workspaceId, + notification_type: data.notificationType, + alert_rule: data.alertConfig?.rule ?? null, + }, + { groups: { workspace: workspaceId } } + ) - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.NOTIFICATION_CREATED, - resourceType: AuditResourceType.NOTIFICATION, - resourceId: subscription.id, - resourceName: data.notificationType, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Created ${data.notificationType} notification subscription`, - request, - }) + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.NOTIFICATION_CREATED, + resourceType: AuditResourceType.NOTIFICATION, + resourceId: subscription.id, + resourceName: data.notificationType, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Created ${data.notificationType} notification subscription`, + request, + }) - return NextResponse.json({ - data: { - id: subscription.id, - notificationType: subscription.notificationType, - workflowIds: subscription.workflowIds, - allWorkflows: subscription.allWorkflows, - levelFilter: subscription.levelFilter, - triggerFilter: subscription.triggerFilter, - includeFinalOutput: subscription.includeFinalOutput, - includeTraceSpans: subscription.includeTraceSpans, - includeRateLimits: subscription.includeRateLimits, - includeUsageData: subscription.includeUsageData, - webhookConfig: subscription.webhookConfig, - emailRecipients: subscription.emailRecipients, - slackConfig: subscription.slackConfig, - alertConfig: subscription.alertConfig, - active: subscription.active, - createdAt: subscription.createdAt, - updatedAt: subscription.updatedAt, - }, - }) - } catch (error) { - logger.error('Error creating notification', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + data: { + id: subscription.id, + notificationType: subscription.notificationType, + workflowIds: subscription.workflowIds, + allWorkflows: subscription.allWorkflows, + levelFilter: subscription.levelFilter, + triggerFilter: subscription.triggerFilter, + includeFinalOutput: subscription.includeFinalOutput, + includeTraceSpans: subscription.includeTraceSpans, + includeRateLimits: subscription.includeRateLimits, + includeUsageData: subscription.includeUsageData, + webhookConfig: subscription.webhookConfig, + emailRecipients: subscription.emailRecipients, + slackConfig: subscription.slackConfig, + alertConfig: subscription.alertConfig, + active: subscription.active, + createdAt: subscription.createdAt, + updatedAt: subscription.updatedAt, + }, + }) + } catch (error) { + logger.error('Error creating notification', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index f31bce34ba6..820a413832b 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -34,42 +35,44 @@ const updatePermissionsSchema = z.object({ * @param workspaceId - The workspace ID from the URL parameters * @returns Array of users with their permissions for the workspace */ -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id: workspaceId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const { id: workspaceId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const userPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) + const userPermission = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityId, workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) + ) ) - ) - .limit(1) + .limit(1) - if (userPermission.length === 0) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } + if (userPermission.length === 0) { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) + } - const result = await getUsersWithPermissions(workspaceId) + const result = await getUsersWithPermissions(workspaceId) - return NextResponse.json({ - users: result, - total: result.length, - }) - } catch (error) { - logger.error('Error fetching workspace permissions:', error) - return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 }) + return NextResponse.json({ + users: result, + total: result.length, + }) + } catch (error) { + logger.error('Error fetching workspace permissions:', error) + return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 }) + } } -} +) /** * PATCH /api/workspaces/[id]/permissions @@ -81,152 +84,154 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ * @param updates - Array of permission updates for users * @returns Success message or error */ -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id: workspaceId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const { id: workspaceId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - if (!hasAdminAccess) { - return NextResponse.json( - { error: 'Admin access required to update permissions' }, - { status: 403 } - ) - } + if (!hasAdminAccess) { + return NextResponse.json( + { error: 'Admin access required to update permissions' }, + { status: 403 } + ) + } - const body = updatePermissionsSchema.parse(await request.json()) + const body = updatePermissionsSchema.parse(await request.json()) - const workspaceRow = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceRow = await db + .select({ billedAccountUserId: workspace.billedAccountUserId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) - if (!workspaceRow.length) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + if (!workspaceRow.length) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - const billedAccountUserId = workspaceRow[0].billedAccountUserId + const billedAccountUserId = workspaceRow[0].billedAccountUserId - const selfUpdate = body.updates.find((update) => update.userId === session.user.id) - if (selfUpdate && selfUpdate.permissions !== 'admin') { - return NextResponse.json( - { error: 'Cannot remove your own admin permissions' }, - { status: 400 } - ) - } + const selfUpdate = body.updates.find((update) => update.userId === session.user.id) + if (selfUpdate && selfUpdate.permissions !== 'admin') { + return NextResponse.json( + { error: 'Cannot remove your own admin permissions' }, + { status: 400 } + ) + } - if ( - billedAccountUserId && - body.updates.some( - (update) => update.userId === billedAccountUserId && update.permissions !== 'admin' - ) - ) { - return NextResponse.json( - { error: 'Workspace billing account must retain admin permissions' }, - { status: 400 } - ) - } + if ( + billedAccountUserId && + body.updates.some( + (update) => update.userId === billedAccountUserId && update.permissions !== 'admin' + ) + ) { + return NextResponse.json( + { error: 'Workspace billing account must retain admin permissions' }, + { status: 400 } + ) + } - // Capture existing permissions and user info for audit metadata - const existingPerms = await db - .select({ - userId: permissions.userId, - permissionType: permissions.permissionType, - email: user.email, - }) - .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + // Capture existing permissions and user info for audit metadata + const existingPerms = await db + .select({ + userId: permissions.userId, + permissionType: permissions.permissionType, + email: user.email, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - const permLookup = new Map( - existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }]) - ) + const permLookup = new Map( + existingPerms.map((p) => [p.userId, { permission: p.permissionType, email: p.email }]) + ) - await db.transaction(async (tx) => { - for (const update of body.updates) { - await tx - .delete(permissions) - .where( - and( - eq(permissions.userId, update.userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + await db.transaction(async (tx) => { + for (const update of body.updates) { + await tx + .delete(permissions) + .where( + and( + eq(permissions.userId, update.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) - ) - await tx.insert(permissions).values({ - id: generateId(), - userId: update.userId, - entityType: 'workspace' as const, - entityId: workspaceId, - permissionType: update.permissions, - createdAt: new Date(), - updatedAt: new Date(), + await tx.insert(permissions).values({ + id: generateId(), + userId: update.userId, + entityType: 'workspace' as const, + entityId: workspaceId, + permissionType: update.permissions, + createdAt: new Date(), + updatedAt: new Date(), + }) + } + }) + + const [wsEnvRow] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, workspaceId)) + .limit(1) + const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId, + envKeys: wsEnvKeys, + actingUserId: session.user.id, }) } - }) - const [wsEnvRow] = await db - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, workspaceId)) - .limit(1) - const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) - if (wsEnvKeys.length > 0) { - await syncWorkspaceEnvCredentials({ - workspaceId, - envKeys: wsEnvKeys, - actingUserId: session.user.id, - }) - } + const updatedUsers = await getUsersWithPermissions(workspaceId) - const updatedUsers = await getUsersWithPermissions(workspaceId) + for (const update of body.updates) { + captureServerEvent( + session.user.id, + 'workspace_member_role_changed', + { workspace_id: workspaceId, new_role: update.permissions }, + { groups: { workspace: workspaceId } } + ) - for (const update of body.updates) { - captureServerEvent( - session.user.id, - 'workspace_member_role_changed', - { workspace_id: workspaceId, new_role: update.permissions }, - { groups: { workspace: workspaceId } } - ) + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Changed permissions for user ${update.userId} to ${update.permissions}`, + metadata: { + targetUserId: update.userId, + targetEmail: permLookup.get(update.userId)?.email ?? undefined, + changes: [ + { + field: 'permissions', + from: permLookup.get(update.userId)?.permission ?? null, + to: update.permissions, + }, + ], + }, + request, + }) + } - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Changed permissions for user ${update.userId} to ${update.permissions}`, - metadata: { - targetUserId: update.userId, - targetEmail: permLookup.get(update.userId)?.email ?? undefined, - changes: [ - { - field: 'permissions', - from: permLookup.get(update.userId)?.permission ?? null, - to: update.permissions, - }, - ], - }, - request, + return NextResponse.json({ + message: 'Permissions updated successfully', + users: updatedUsers, + total: updatedUsers.length, }) + } catch (error) { + logger.error('Error updating workspace permissions:', error) + return NextResponse.json({ error: 'Failed to update workspace permissions' }, { status: 500 }) } - - return NextResponse.json({ - message: 'Permissions updated successfully', - users: updatedUsers, - total: updatedUsers.length, - }) - } catch (error) { - logger.error('Error updating workspace permissions:', error) - return NextResponse.json({ error: 'Failed to update workspace permissions' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts index 65543049416..c07fda1eb82 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { generatePptxFromCode } from '@/lib/execution/pptx-vm' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -13,50 +14,53 @@ const logger = createLogger('PptxPreviewAPI') * POST /api/workspaces/[id]/pptx/preview * Compile PptxGenJS source code and return the binary PPTX for streaming preview. */ -export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: workspaceId } = await params +export const POST = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: workspaceId } = await params - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) + if (!membership) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }) - } - const { code } = body as { code?: string } + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid or missing JSON body' }, { status: 400 }) + } + const { code } = body as { code?: string } - if (typeof code !== 'string' || code.trim().length === 0) { - return NextResponse.json({ error: 'code is required' }, { status: 400 }) - } + if (typeof code !== 'string' || code.trim().length === 0) { + return NextResponse.json({ error: 'code is required' }, { status: 400 }) + } - const MAX_CODE_BYTES = 512 * 1024 - if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { - return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) - } + const MAX_CODE_BYTES = 512 * 1024 + if (Buffer.byteLength(code, 'utf-8') > MAX_CODE_BYTES) { + return NextResponse.json({ error: 'code exceeds maximum size' }, { status: 413 }) + } - const buffer = await generatePptxFromCode(code, workspaceId, req.signal) - - return new NextResponse(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'Content-Length': String(buffer.length), - 'Cache-Control': 'private, no-store', - }, - }) - } catch (err) { - const message = err instanceof Error ? err.message : 'PPTX generation failed' - logger.error('PPTX preview generation failed', { error: message, workspaceId }) - return NextResponse.json({ error: message }, { status: 500 }) + const buffer = await generatePptxFromCode(code, workspaceId, req.signal) + + return new NextResponse(new Uint8Array(buffer), { + status: 200, + headers: { + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'Content-Length': String(buffer.length), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'PPTX generation failed' + logger.error('PPTX preview generation failed', { error: message, workspaceId }) + return NextResponse.json({ error: message }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 375e0879b8b..56a9a440277 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -12,6 +12,7 @@ const logger = createLogger('WorkspaceByIdAPI') import { db } from '@sim/db' import { permissions, templates, workspace } from '@sim/db/schema' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const patchWorkspaceSchema = z.object({ @@ -28,286 +29,291 @@ const deleteWorkspaceSchema = z.object({ deleteTemplates: z.boolean().default(false), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const workspaceId = id - const url = new URL(request.url) - const checkTemplates = url.searchParams.get('check-templates') === 'true' + const workspaceId = id + const url = new URL(request.url) + const checkTemplates = url.searchParams.get('check-templates') === 'true' - // Check if user has any access to this workspace - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!userPermission) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } + // Check if user has any access to this workspace + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!userPermission) { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) + } - // If checking for published templates before deletion - if (checkTemplates) { - try { - // Get all workflows in this workspace - const workspaceWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + // If checking for published templates before deletion + if (checkTemplates) { + try { + // Get all workflows in this workspace + const workspaceWorkflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + + if (workspaceWorkflows.length === 0) { + return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] }) + } + + const workflowIds = workspaceWorkflows.map((w) => w.id) + + // Check for published templates that reference these workflows + const publishedTemplates = await db + .select({ + id: templates.id, + name: templates.name, + workflowId: templates.workflowId, + }) + .from(templates) + .where(inArray(templates.workflowId, workflowIds)) - if (workspaceWorkflows.length === 0) { - return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] }) + return NextResponse.json({ + hasPublishedTemplates: publishedTemplates.length > 0, + publishedTemplates, + count: publishedTemplates.length, + }) + } catch (error) { + logger.error(`Error checking published templates for workspace ${workspaceId}:`, error) + return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 }) } + } - const workflowIds = workspaceWorkflows.map((w) => w.id) - - // Check for published templates that reference these workflows - const publishedTemplates = await db - .select({ - id: templates.id, - name: templates.name, - workflowId: templates.workflowId, - }) - .from(templates) - .where(inArray(templates.workflowId, workflowIds)) + // Get workspace details + const workspaceDetails = await db + .select() + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .then((rows) => rows[0]) - return NextResponse.json({ - hasPublishedTemplates: publishedTemplates.length > 0, - publishedTemplates, - count: publishedTemplates.length, - }) - } catch (error) { - logger.error(`Error checking published templates for workspace ${workspaceId}:`, error) - return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 }) + if (!workspaceDetails) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - } - - // Get workspace details - const workspaceDetails = await db - .select() - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .then((rows) => rows[0]) - if (!workspaceDetails) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + return NextResponse.json({ + workspace: { + ...workspaceDetails, + permissions: userPermission, + }, + }) } +) - return NextResponse.json({ - workspace: { - ...workspaceDetails, - permissions: userPermission, - }, - }) -} - -export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const workspaceId = id + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - // Check if user has admin permissions to update workspace - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + const workspaceId = id - try { - const body = patchWorkspaceSchema.parse(await request.json()) - const { name, color, billedAccountUserId, allowPersonalApiKeys } = body - - if ( - name === undefined && - color === undefined && - billedAccountUserId === undefined && - allowPersonalApiKeys === undefined - ) { - return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) + // Check if user has admin permissions to update workspace + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const existingWorkspace = await db - .select() - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .then((rows) => rows[0]) + try { + const body = patchWorkspaceSchema.parse(await request.json()) + const { name, color, billedAccountUserId, allowPersonalApiKeys } = body + + if ( + name === undefined && + color === undefined && + billedAccountUserId === undefined && + allowPersonalApiKeys === undefined + ) { + return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) + } - if (!existingWorkspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + const existingWorkspace = await db + .select() + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .then((rows) => rows[0]) - const updateData: Record = {} + if (!existingWorkspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - if (name !== undefined) { - updateData.name = name - } + const updateData: Record = {} - if (color !== undefined) { - updateData.color = color - } + if (name !== undefined) { + updateData.name = name + } - if (allowPersonalApiKeys !== undefined) { - updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys) - } + if (color !== undefined) { + updateData.color = color + } + + if (allowPersonalApiKeys !== undefined) { + updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys) + } - if (billedAccountUserId !== undefined) { - const candidateId = billedAccountUserId + if (billedAccountUserId !== undefined) { + const candidateId = billedAccountUserId - const isOwner = candidateId === existingWorkspace.ownerId + const isOwner = candidateId === existingWorkspace.ownerId - let hasAdminAccess = isOwner + let hasAdminAccess = isOwner - if (!hasAdminAccess) { - const adminPermission = await db - .select({ id: permissions.id }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, candidateId), - eq(permissions.permissionType, 'admin') + if (!hasAdminAccess) { + const adminPermission = await db + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.userId, candidateId), + eq(permissions.permissionType, 'admin') + ) ) + .limit(1) + + hasAdminAccess = adminPermission.length > 0 + } + + if (!hasAdminAccess) { + return NextResponse.json( + { error: 'Billed account must be a workspace admin' }, + { status: 400 } ) - .limit(1) + } - hasAdminAccess = adminPermission.length > 0 + updateData.billedAccountUserId = candidateId } - if (!hasAdminAccess) { - return NextResponse.json( - { error: 'Billed account must be a workspace admin' }, - { status: 400 } - ) + if (Object.keys(updateData).length === 0) { + return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 }) } - updateData.billedAccountUserId = candidateId - } + updateData.updatedAt = new Date() - if (Object.keys(updateData).length === 0) { - return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 }) - } + await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId)) - updateData.updatedAt = new Date() + const updatedWorkspace = await db + .select() + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .then((rows) => rows[0]) - await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId)) + return NextResponse.json({ + workspace: { + ...updatedWorkspace, + permissions: userPermission, + }, + }) + } catch (error) { + logger.error('Error updating workspace:', error) + return NextResponse.json({ error: 'Failed to update workspace' }, { status: 500 }) + } + } +) - const updatedWorkspace = await db - .select() - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .then((rows) => rows[0]) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - return NextResponse.json({ - workspace: { - ...updatedWorkspace, - permissions: userPermission, - }, - }) - } catch (error) { - logger.error('Error updating workspace:', error) - return NextResponse.json({ error: 'Failed to update workspace' }, { status: 500 }) - } -} + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const session = await getSession() + const workspaceId = id + const body = deleteWorkspaceSchema.parse(await request.json().catch(() => ({}))) + const { deleteTemplates } = body // User's choice: false = keep templates (recommended), true = delete templates - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Check if user has admin permissions to delete workspace + const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (userPermission !== 'admin') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - const workspaceId = id - const body = deleteWorkspaceSchema.parse(await request.json().catch(() => ({}))) - const { deleteTemplates } = body // User's choice: false = keep templates (recommended), true = delete templates + try { + logger.info( + `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` + ) - // Check if user has admin permissions to delete workspace - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + // Fetch workspace name before deletion for audit logging + const [workspaceRecord] = await db + .select({ name: workspace.name }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) - try { - logger.info( - `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` - ) + const workspaceWorkflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) - // Fetch workspace name before deletion for audit logging - const [workspaceRecord] = await db - .select({ name: workspace.name }) - .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1) - - const workspaceWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - - const workflowIds = workspaceWorkflows.map((entry) => entry.id) - - if (workflowIds.length > 0) { - if (deleteTemplates) { - await db.delete(templates).where(inArray(templates.workflowId, workflowIds)) - } else { - await db - .update(templates) - .set({ workflowId: null }) - .where(inArray(templates.workflowId, workflowIds)) + const workflowIds = workspaceWorkflows.map((entry) => entry.id) + + if (workflowIds.length > 0) { + if (deleteTemplates) { + await db.delete(templates).where(inArray(templates.workflowId, workflowIds)) + } else { + await db + .update(templates) + .set({ workflowId: null }) + .where(inArray(templates.workflowId, workflowIds)) + } } - } - const archiveResult = await archiveWorkspace(workspaceId, { - requestId: `workspace-${workspaceId}`, - }) + const archiveResult = await archiveWorkspace(workspaceId, { + requestId: `workspace-${workspaceId}`, + }) - if (!archiveResult.archived && !workspaceRecord) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + if (!archiveResult.archived && !workspaceRecord) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.WORKSPACE_DELETED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: workspaceRecord?.name, - description: `Archived workspace "${workspaceRecord?.name || workspaceId}"`, - metadata: { - affected: { - workflows: workflowIds.length, + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_DELETED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: workspaceRecord?.name, + description: `Archived workspace "${workspaceRecord?.name || workspaceId}"`, + metadata: { + affected: { + workflows: workflowIds.length, + }, + archived: archiveResult.archived, + deleteTemplates, }, - archived: archiveResult.archived, - deleteTemplates, - }, - request, - }) + request, + }) + + captureServerEvent( + session.user.id, + 'workspace_deleted', + { workspace_id: workspaceId, workflow_count: workflowIds.length }, + { groups: { workspace: workspaceId } } + ) - captureServerEvent( - session.user.id, - 'workspace_deleted', - { workspace_id: workspaceId, workflow_count: workflowIds.length }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`Error deleting workspace ${workspaceId}:`, error) - return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error(`Error deleting workspace ${workspaceId}:`, error) + return NextResponse.json({ error: 'Failed to delete workspace' }, { status: 500 }) + } } -} +) -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - // Reuse the PATCH handler implementation for PUT requests - return PATCH(request, { params }) -} +export const PUT = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + // Reuse the PATCH handler implementation for PUT requests + return PATCH(request, { params }) + } +) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index 602b60e88cd..59c5b8dae6a 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -16,6 +16,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' @@ -24,345 +25,347 @@ import { getWorkspaceById, hasWorkspaceAdminAccess } from '@/lib/workspaces/perm const logger = createLogger('WorkspaceInvitationAPI') // GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token -export async function GET( - req: NextRequest, - { params }: { params: Promise<{ invitationId: string }> } -) { - const { invitationId } = await params - const session = await getSession() - const token = req.nextUrl.searchParams.get('token') - const isAcceptFlow = !!token // If token is provided, this is an acceptance flow - - if (!session?.user?.id) { - if (isAcceptFlow) { - return NextResponse.redirect(new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl())) - } - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const whereClause = token - ? eq(workspaceInvitation.token, token) - : eq(workspaceInvitation.id, invitationId) - - const invitation = await db - .select() - .from(workspaceInvitation) - .where(whereClause) - .then((rows) => rows[0]) - - if (!invitation) { +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ invitationId: string }> }) => { + const { invitationId } = await params + const session = await getSession() + const token = req.nextUrl.searchParams.get('token') + const isAcceptFlow = !!token // If token is provided, this is an acceptance flow + + if (!session?.user?.id) { if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' return NextResponse.redirect( - new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl()) + new URL(`/invite/${invitationId}?token=${token}`, getBaseUrl()) ) } - return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (new Date() > new Date(invitation.expiresAt)) { - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl()) - ) - } - return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) - } + try { + const whereClause = token + ? eq(workspaceInvitation.token, token) + : eq(workspaceInvitation.id, invitationId) - const workspaceDetails = await db - .select() - .from(workspace) - .where(and(eq(workspace.id, invitation.workspaceId), isNull(workspace.archivedAt))) - .then((rows) => rows[0]) + const invitation = await db + .select() + .from(workspaceInvitation) + .where(whereClause) + .then((rows) => rows[0]) - if (!workspaceDetails) { - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl()) - ) + if (!invitation) { + if (isAcceptFlow) { + const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' + return NextResponse.redirect( + new URL(`/invite/${invitationId}?error=invalid-token${tokenParam}`, getBaseUrl()) + ) + } + return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 }) } - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - if (isAcceptFlow) { - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' - - if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl()) - ) + if (new Date() > new Date(invitation.expiresAt)) { + if (isAcceptFlow) { + const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' + return NextResponse.redirect( + new URL(`/invite/${invitation.id}?error=expired${tokenParam}`, getBaseUrl()) + ) + } + return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 }) } - const userEmail = session.user.email.toLowerCase() - const invitationEmail = invitation.email.toLowerCase() - - const userData = await db + const workspaceDetails = await db .select() - .from(user) - .where(eq(user.id, session.user.id)) + .from(workspace) + .where(and(eq(workspace.id, invitation.workspaceId), isNull(workspace.archivedAt))) .then((rows) => rows[0]) - if (!userData) { - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl()) - ) + if (!workspaceDetails) { + if (isAcceptFlow) { + const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' + return NextResponse.redirect( + new URL(`/invite/${invitation.id}?error=workspace-not-found${tokenParam}`, getBaseUrl()) + ) + } + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - const isValidMatch = userEmail === invitationEmail + if (isAcceptFlow) { + const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '' + + if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { + return NextResponse.redirect( + new URL(`/invite/${invitation.id}?error=already-processed${tokenParam}`, getBaseUrl()) + ) + } - if (!isValidMatch) { - return NextResponse.redirect( - new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl()) - ) - } + const userEmail = session.user.email.toLowerCase() + const invitationEmail = invitation.email.toLowerCase() - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, invitation.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) + const userData = await db + .select() + .from(user) + .where(eq(user.id, session.user.id)) + .then((rows) => rows[0]) + + if (!userData) { + return NextResponse.redirect( + new URL(`/invite/${invitation.id}?error=user-not-found${tokenParam}`, getBaseUrl()) ) - ) - .then((rows) => rows[0]) + } + + const isValidMatch = userEmail === invitationEmail - if (existingPermission) { - await db - .update(workspaceInvitation) - .set({ - status: 'accepted' as WorkspaceInvitationStatus, + if (!isValidMatch) { + return NextResponse.redirect( + new URL(`/invite/${invitation.id}?error=email-mismatch${tokenParam}`, getBaseUrl()) + ) + } + + const existingPermission = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityId, invitation.workspaceId), + eq(permissions.entityType, 'workspace'), + eq(permissions.userId, session.user.id) + ) + ) + .then((rows) => rows[0]) + + if (existingPermission) { + await db + .update(workspaceInvitation) + .set({ + status: 'accepted' as WorkspaceInvitationStatus, + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) + + return NextResponse.redirect( + new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl()) + ) + } + + await db.transaction(async (tx) => { + await tx.insert(permissions).values({ + id: generateId(), + entityType: 'workspace' as const, + entityId: invitation.workspaceId, + userId: session.user.id, + permissionType: invitation.permissions || 'read', + createdAt: new Date(), updatedAt: new Date(), }) - .where(eq(workspaceInvitation.id, invitation.id)) - return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl()) - ) - } - - await db.transaction(async (tx) => { - await tx.insert(permissions).values({ - id: generateId(), - entityType: 'workspace' as const, - entityId: invitation.workspaceId, - userId: session.user.id, - permissionType: invitation.permissions || 'read', - createdAt: new Date(), - updatedAt: new Date(), + await tx + .update(workspaceInvitation) + .set({ + status: 'accepted' as WorkspaceInvitationStatus, + updatedAt: new Date(), + }) + .where(eq(workspaceInvitation.id, invitation.id)) }) - await tx - .update(workspaceInvitation) - .set({ - status: 'accepted' as WorkspaceInvitationStatus, - updatedAt: new Date(), + const [wsEnvRow] = await db + .select({ variables: workspaceEnvironment.variables }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId)) + .limit(1) + const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) + if (wsEnvKeys.length > 0) { + await syncWorkspaceEnvCredentials({ + workspaceId: invitation.workspaceId, + envKeys: wsEnvKeys, + actingUserId: session.user.id, }) - .where(eq(workspaceInvitation.id, invitation.id)) - }) + } - const [wsEnvRow] = await db - .select({ variables: workspaceEnvironment.variables }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId)) - .limit(1) - const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record) || {}) - if (wsEnvKeys.length > 0) { - await syncWorkspaceEnvCredentials({ + recordAudit({ workspaceId: invitation.workspaceId, - envKeys: wsEnvKeys, - actingUserId: session.user.id, + actorId: session.user.id, + action: AuditAction.INVITATION_ACCEPTED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: invitation.workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: workspaceDetails.name, + description: `Accepted workspace invitation to "${workspaceDetails.name}"`, + metadata: { targetEmail: invitation.email }, + request: req, }) + + return NextResponse.redirect( + new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl()) + ) } - recordAudit({ - workspaceId: invitation.workspaceId, - actorId: session.user.id, - action: AuditAction.INVITATION_ACCEPTED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: invitation.workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: workspaceDetails.name, - description: `Accepted workspace invitation to "${workspaceDetails.name}"`, - metadata: { targetEmail: invitation.email }, - request: req, + const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase() + + if (!isInvitee) { + const hasAdminAccess = await hasWorkspaceAdminAccess( + session.user.id, + invitation.workspaceId + ) + if (!hasAdminAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + } + + return NextResponse.json({ + ...invitation, + workspaceName: workspaceDetails.name, }) + } catch (error) { + logger.error('Error fetching workspace invitation:', error) + return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) + } + } +) - return NextResponse.redirect( - new URL(`/workspace/${invitation.workspaceId}/home`, getBaseUrl()) - ) +// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation +export const DELETE = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ invitationId: string }> }) => { + const { invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const isInvitee = session.user.email?.toLowerCase() === invitation.email.toLowerCase() + try { + const invitation = await db + .select({ + id: workspaceInvitation.id, + workspaceId: workspaceInvitation.workspaceId, + email: workspaceInvitation.email, + inviterId: workspaceInvitation.inviterId, + status: workspaceInvitation.status, + }) + .from(workspaceInvitation) + .where(eq(workspaceInvitation.id, invitationId)) + .then((rows) => rows[0]) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + const activeWorkspace = await getWorkspaceById(invitation.workspaceId) + if (!activeWorkspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - if (!isInvitee) { const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) + if (!hasAdminAccess) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - } - return NextResponse.json({ - ...invitation, - workspaceName: workspaceDetails.name, - }) - } catch (error) { - logger.error('Error fetching workspace invitation:', error) - return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 }) - } -} + if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { + return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 }) + } -// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation -export async function DELETE( - _request: NextRequest, - { params }: { params: Promise<{ invitationId: string }> } -) { - const { invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId)) - try { - const invitation = await db - .select({ - id: workspaceInvitation.id, - workspaceId: workspaceInvitation.workspaceId, - email: workspaceInvitation.email, - inviterId: workspaceInvitation.inviterId, - status: workspaceInvitation.status, + recordAudit({ + workspaceId: invitation.workspaceId, + actorId: session.user.id, + action: AuditAction.INVITATION_REVOKED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: invitation.workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Revoked workspace invitation for ${invitation.email}`, + metadata: { invitationId, targetEmail: invitation.email }, + request: _request, }) - .from(workspaceInvitation) - .where(eq(workspaceInvitation.id, invitationId)) - .then((rows) => rows[0]) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - const activeWorkspace = await getWorkspaceById(invitation.workspaceId) - if (!activeWorkspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting workspace invitation:', error) + return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 }) } + } +) - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) +// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation +export const POST = withRouteHandler( + async (_request: NextRequest, { params }: { params: Promise<{ invitationId: string }> }) => { + const { invitationId } = await params + const session = await getSession() - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { - return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 }) - } + try { + const invitation = await db + .select() + .from(workspaceInvitation) + .where(eq(workspaceInvitation.id, invitationId)) + .then((rows) => rows[0]) - await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId)) - - recordAudit({ - workspaceId: invitation.workspaceId, - actorId: session.user.id, - action: AuditAction.INVITATION_REVOKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: invitation.workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Revoked workspace invitation for ${invitation.email}`, - metadata: { invitationId, targetEmail: invitation.email }, - request: _request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting workspace invitation:', error) - return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 }) - } -} + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } -// POST /api/workspaces/invitations/[invitationId] - Resend a workspace invitation -export async function POST( - _request: NextRequest, - { params }: { params: Promise<{ invitationId: string }> } -) { - const { invitationId } = await params - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) + if (!hasAdminAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } - try { - const invitation = await db - .select() - .from(workspaceInvitation) - .where(eq(workspaceInvitation.id, invitationId)) - .then((rows) => rows[0]) + if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { + return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) + } - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } + const ws = await db + .select() + .from(workspace) + .where(eq(workspace.id, invitation.workspaceId)) + .then((rows) => rows[0]) - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) - if (!hasAdminAccess) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) { - return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) - } + const newToken = generateId() + const newExpiresAt = new Date() + newExpiresAt.setDate(newExpiresAt.getDate() + 7) - const ws = await db - .select() - .from(workspace) - .where(eq(workspace.id, invitation.workspaceId)) - .then((rows) => rows[0]) + await db + .update(workspaceInvitation) + .set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() }) + .where(eq(workspaceInvitation.id, invitationId)) - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + const baseUrl = getBaseUrl() + const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}` - const newToken = generateId() - const newExpiresAt = new Date() - newExpiresAt.setDate(newExpiresAt.getDate() + 7) + const emailHtml = await render( + WorkspaceInvitationEmail({ + workspaceName: ws.name, + inviterName: session.user.name || session.user.email || 'A user', + invitationLink, + }) + ) - await db - .update(workspaceInvitation) - .set({ token: newToken, expiresAt: newExpiresAt, updatedAt: new Date() }) - .where(eq(workspaceInvitation.id, invitationId)) + const result = await sendEmail({ + to: invitation.email, + subject: `You've been invited to join "${ws.name}" on Sim`, + html: emailHtml, + from: getFromEmailAddress(), + emailType: 'transactional', + }) - const baseUrl = getBaseUrl() - const invitationLink = `${baseUrl}/invite/${invitationId}?token=${newToken}` + if (!result.success) { + return NextResponse.json( + { error: 'Failed to send invitation email. Please try again.' }, + { status: 500 } + ) + } - const emailHtml = await render( - WorkspaceInvitationEmail({ - workspaceName: ws.name, - inviterName: session.user.name || session.user.email || 'A user', - invitationLink, - }) - ) - - const result = await sendEmail({ - to: invitation.email, - subject: `You've been invited to join "${ws.name}" on Sim`, - html: emailHtml, - from: getFromEmailAddress(), - emailType: 'transactional', - }) - - if (!result.success) { - return NextResponse.json( - { error: 'Failed to send invitation email. Please try again.' }, - { status: 500 } - ) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error resending workspace invitation:', error) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) } - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error resending workspace invitation:', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 30c91acd22f..c6e7c3e2183 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -17,6 +17,7 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' import { captureServerEvent } from '@/lib/posthog/server' @@ -33,7 +34,7 @@ const logger = createLogger('WorkspaceInvitationsAPI') type PermissionType = (typeof permissionTypeEnum.enumValues)[number] // Get all invitations for the user's workspaces -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -70,10 +71,10 @@ export async function GET(req: NextRequest) { logger.error('Error fetching workspace invitations:', error) return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) } -} +}) // Create a new invitation -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { @@ -255,7 +256,7 @@ export async function POST(req: NextRequest) { logger.error('Error creating workspace invitation:', error) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } -} +}) async function sendInvitationEmail({ to, diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index ca918712946..aa65893b729 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' @@ -16,119 +17,121 @@ const deleteMemberSchema = z.object({ }) // DELETE /api/workspaces/members/[id] - Remove a member from a workspace -export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id: userId } = await params - const session = await getSession() +export const DELETE = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id: userId } = await params + const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // Get the workspace ID from the request body or URL - const body = deleteMemberSchema.parse(await req.json()) - const { workspaceId } = body - - const workspaceRow = await db - .select({ billedAccountUserId: workspace.billedAccountUserId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - if (!workspaceRow.length) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (workspaceRow[0].billedAccountUserId === userId) { - return NextResponse.json( - { error: 'Cannot remove the workspace billing account. Please reassign billing first.' }, - { status: 400 } - ) - } - - // Check if the user to be removed actually has permissions for this workspace - const userPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .then((rows) => rows[0]) + try { + // Get the workspace ID from the request body or URL + const body = deleteMemberSchema.parse(await req.json()) + const { workspaceId } = body - if (!userPermission) { - return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) - } + const workspaceRow = await db + .select({ billedAccountUserId: workspace.billedAccountUserId }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) - // Check if current user has admin access to this workspace - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - const isSelf = userId === session.user.id + if (!workspaceRow.length) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - if (!hasAdminAccess && !isSelf) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } + if (workspaceRow[0].billedAccountUserId === userId) { + return NextResponse.json( + { error: 'Cannot remove the workspace billing account. Please reassign billing first.' }, + { status: 400 } + ) + } - // Prevent removing yourself if you're the last admin - if (isSelf && userPermission.permissionType === 'admin') { - const otherAdmins = await db + // Check if the user to be removed actually has permissions for this workspace + const userPermission = await db .select() .from(permissions) .where( and( + eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.permissionType, 'admin') + eq(permissions.entityId, workspaceId) ) ) - .then((rows) => rows.filter((row) => row.userId !== session.user.id)) + .then((rows) => rows[0]) - if (otherAdmins.length === 0) { - return NextResponse.json( - { error: 'Cannot remove the last admin from a workspace' }, - { status: 400 } - ) + if (!userPermission) { + return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) + } + + // Check if current user has admin access to this workspace + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) + const isSelf = userId === session.user.id + + if (!hasAdminAccess && !isSelf) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - } - // Delete the user's permissions for this workspace - await db - .delete(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) + // Prevent removing yourself if you're the last admin + if (isSelf && userPermission.permissionType === 'admin') { + const otherAdmins = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.permissionType, 'admin') + ) + ) + .then((rows) => rows.filter((row) => row.userId !== session.user.id)) + + if (otherAdmins.length === 0) { + return NextResponse.json( + { error: 'Cannot remove the last admin from a workspace' }, + { status: 400 } + ) + } + } + + // Delete the user's permissions for this workspace + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) ) + + await revokeWorkspaceCredentialMemberships(workspaceId, userId) + + captureServerEvent( + session.user.id, + 'workspace_member_removed', + { workspace_id: workspaceId, is_self_removal: isSelf }, + { groups: { workspace: workspaceId } } ) - await revokeWorkspaceCredentialMemberships(workspaceId, userId) - - captureServerEvent( - session.user.id, - 'workspace_member_removed', - { workspace_id: workspaceId, is_self_removal: isSelf }, - { groups: { workspace: workspaceId } } - ) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.MEMBER_REMOVED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - description: isSelf ? 'Left the workspace' : 'Removed a member from the workspace', - metadata: { removedUserId: userId, selfRemoval: isSelf }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error removing workspace member:', error) - return NextResponse.json({ error: 'Failed to remove workspace member' }, { status: 500 }) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.MEMBER_REMOVED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: isSelf ? 'Left the workspace' : 'Removed a member from the workspace', + metadata: { removedUserId: userId, selfRemoval: isSelf }, + request: req, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error removing workspace member:', error) + return NextResponse.json({ error: 'Failed to remove workspace member' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index d64fd05f758..7bac16451ed 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -8,6 +8,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateId } from '@/lib/core/utils/uuid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -26,7 +27,7 @@ const createWorkspaceSchema = z.object({ }) // Get all workspaces for the current user -export async function GET(request: Request) { +export const GET = withRouteHandler(async (request: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -83,10 +84,10 @@ export async function GET(request: Request) { ) return NextResponse.json({ workspaces: workspacesWithPermissions }) -} +}) // POST /api/workspaces - Create a new workspace -export async function POST(req: Request) { +export const POST = withRouteHandler(async (req: Request) => { const session = await getSession() if (!session?.user?.id) { @@ -127,7 +128,7 @@ export async function POST(req: Request) { logger.error('Error creating workspace:', error) return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 }) } -} +}) async function createDefaultWorkspace(userId: string, userName?: string | null) { const firstName = userName?.split(' ')[0] || null diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts new file mode 100644 index 00000000000..81a30b21cbd --- /dev/null +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -0,0 +1,55 @@ +import { createLogger, runWithRequestContext } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { generateRequestId } from '@/lib/core/utils/request' + +const logger = createLogger('RouteHandler') + +type RouteHandler = ( + request: NextRequest, + context: T +) => Promise | NextResponse | Response + +/** + * Wraps a Next.js API route handler with centralized error reporting. + * + * - Generates a unique request ID and stores it in AsyncLocalStorage so every + * logger in the request lifecycle automatically includes it + * - Logs all 4xx and 5xx responses with method, path, status, duration + * - Catches unhandled errors, logs them, and returns a 500 with the request ID + * - Attaches `x-request-id` response header + */ +export function withRouteHandler(handler: RouteHandler): RouteHandler { + return async (request: NextRequest, context: T) => { + const requestId = generateRequestId() + const startTime = Date.now() + const method = request.method + const path = request.nextUrl.pathname + + return runWithRequestContext({ requestId, method, path }, async () => { + let response: NextResponse | Response + try { + response = await handler(request, context) + } catch (error) { + const duration = Date.now() - startTime + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Unhandled route error', { duration, error: message }) + response = NextResponse.json({ error: 'Internal server error', requestId }, { status: 500 }) + response.headers.set('x-request-id', requestId) + return response + } + + const status = response.status + const duration = Date.now() - startTime + + if (status >= 500) { + logger.error('Server error response', { status, duration }) + } else if (status >= 400) { + logger.warn('Client error response', { status, duration }) + } + + response.headers.set('x-request-id', requestId) + return response + }) + } +} diff --git a/packages/logger/src/index.test.ts b/packages/logger/src/index.test.ts index db14f191603..076fd74c293 100644 --- a/packages/logger/src/index.test.ts +++ b/packages/logger/src/index.test.ts @@ -170,47 +170,50 @@ describe('Logger', () => { const child = createEnabledLogger().withMetadata({ workflowId: 'wf_1' }) child.info('hello') expect(consoleLogSpy).toHaveBeenCalledTimes(1) - const prefix = consoleLogSpy.mock.calls[0][0] as string - expect(prefix).toContain('{workflowId=wf_1}') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.workflowId).toBe('wf_1') + expect(parsed.message).toBe('hello') }) test('should not affect original logger output', () => { const logger = createEnabledLogger() logger.withMetadata({ workflowId: 'wf_1' }) logger.info('hello') - const prefix = consoleLogSpy.mock.calls[0][0] as string - expect(prefix).not.toContain('workflowId') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.workflowId).toBeUndefined() }) test('should merge metadata across chained calls', () => { const child = createEnabledLogger().withMetadata({ a: '1' }).withMetadata({ b: '2' }) child.info('hello') - const prefix = consoleLogSpy.mock.calls[0][0] as string - expect(prefix).toContain('{a=1 b=2}') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.a).toBe('1') + expect(parsed.b).toBe('2') }) test('should override parent metadata for same key', () => { const child = createEnabledLogger().withMetadata({ a: '1' }).withMetadata({ a: '2' }) child.info('hello') - const prefix = consoleLogSpy.mock.calls[0][0] as string - expect(prefix).toContain('{a=2}') - expect(prefix).not.toContain('a=1') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.a).toBe('2') }) test('should exclude undefined values from output', () => { const child = createEnabledLogger().withMetadata({ a: '1', b: undefined }) child.info('hello') - const prefix = consoleLogSpy.mock.calls[0][0] as string - expect(prefix).toContain('{a=1}') - expect(prefix).not.toContain('b=') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.a).toBe('1') + expect(parsed.b).toBeUndefined() }) test('should produce no metadata segment when metadata is empty', () => { const child = createEnabledLogger().withMetadata({}) child.info('hello') - const prefix = consoleLogSpy.mock.calls[0][0] as string - expect(prefix).not.toContain('{') - expect(prefix).not.toContain('}') + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.message).toBe('hello') + expect(Object.keys(parsed)).toEqual( + expect.arrayContaining(['timestamp', 'level', 'module', 'message']) + ) }) }) }) diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index eb28fec3fcf..d94134ea779 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -5,6 +5,7 @@ * Provides standardized console logging with environment-aware configuration. */ import chalk from 'chalk' +import { getRequestContext } from './request-context' /** * LogLevel enum defines the severity levels for logging @@ -230,7 +231,11 @@ export class Logger { const timestamp = new Date().toISOString() const formattedArgs = this.formatArgs(args) - const metadataEntries = Object.entries(this.metadata).filter(([_, v]) => v !== undefined) + const reqCtx = getRequestContext() + const effectiveMetadata = reqCtx + ? { requestId: reqCtx.requestId, ...this.metadata } + : this.metadata + const metadataEntries = Object.entries(effectiveMetadata).filter(([_, v]) => v !== undefined) const metadataStr = metadataEntries.length > 0 ? ` {${metadataEntries.map(([k, v]) => `${k}=${v}`).join(' ')}}` @@ -265,12 +270,38 @@ export class Logger { console.log(coloredPrefix, message, ...formattedArgs) } } else { - const prefix = `[${timestamp}] [${level}] [${this.module}]${metadataStr}` + // Structured JSON for production — CloudWatch Log Insights auto-parses JSON lines + const entry: Record = { + timestamp, + level, + module: this.module, + message, + } + for (const [k, v] of metadataEntries) { + entry[k] = v + } + // Merge extra args into the entry + for (const arg of args) { + if ( + arg !== null && + arg !== undefined && + typeof arg === 'object' && + !(arg instanceof Error) + ) { + Object.assign(entry, arg) + } else if (arg instanceof Error) { + entry.error = arg.message + entry.stack = arg.stack + } else if (arg !== null && arg !== undefined) { + entry.extra = arg + } + } + const line = JSON.stringify(entry) if (level === LogLevel.ERROR) { - console.error(prefix, message, ...formattedArgs) + console.error(line) } else { - console.log(prefix, message, ...formattedArgs) + console.log(line) } } } @@ -335,3 +366,6 @@ export class Logger { export function createLogger(module: string, config?: LoggerConfig): Logger { return new Logger(module, config) } + +export type { RequestContext } from './request-context' +export { getRequestContext, runWithRequestContext } from './request-context' diff --git a/packages/logger/src/request-context.ts b/packages/logger/src/request-context.ts new file mode 100644 index 00000000000..3d6613bbc53 --- /dev/null +++ b/packages/logger/src/request-context.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +export interface RequestContext { + requestId: string + method?: string + path?: string +} + +export const requestContextStorage = new AsyncLocalStorage() + +/** + * Runs a callback within a request context. All loggers called inside + * the callback (and any async functions it awaits) will automatically + * include the request context metadata in their output. + */ +export function runWithRequestContext(context: RequestContext, fn: () => T): T { + return requestContextStorage.run(context, fn) +} + +/** + * Returns the current request context, or undefined if called outside + * of a `runWithRequestContext` scope. + */ +export function getRequestContext(): RequestContext | undefined { + return requestContextStorage.getStore() +}