diff --git a/.claude/rules/global.md b/.claude/rules/global.md index cfcb4c7cc29..b5bc94ec1b2 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 1ca9bf41a25..bc4797c8314 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 `@sim/utils/id` @@ -93,6 +94,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 0ff2d6e017d..082b17f42ca 100644 --- a/apps/sim/app/api/a2a/agents/route.ts +++ b/apps/sim/app/api/a2a/agents/route.ts @@ -14,6 +14,7 @@ import { generateSkillsFromWorkflow } from '@/lib/a2a/agent-card' import { A2A_DEFAULT_CAPABILITIES } from '@/lib/a2a/constants' import { sanitizeAgentName } from '@/lib/a2a/utils' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +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 768017e4f4b..2a304445a14 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 { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' import { getClientIp } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' +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 aefca60a9ee..ba18d585260 100644 --- a/apps/sim/app/api/academy/certificates/route.ts +++ b/apps/sim/app/api/academy/certificates/route.ts @@ -10,6 +10,7 @@ import type { CertificateMetadata } from '@/lib/academy/types' import { getSession } from '@/lib/auth' import type { TokenBucketConfig } from '@/lib/core/rate-limiter' import { RateLimiter } from '@/lib/core/rate-limiter' +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/admin/mothership/route.ts b/apps/sim/app/api/admin/mothership/route.ts index c298370ed39..19e4a029ec4 100644 --- a/apps/sim/app/api/admin/mothership/route.ts +++ b/apps/sim/app/api/admin/mothership/route.ts @@ -4,6 +4,7 @@ import { 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' const ENV_URLS: Record = { dev: env.MOTHERSHIP_DEV_URL, @@ -38,7 +39,7 @@ async function isAdminRequestAuthorized() { * The request body (for POST) is forwarded as-is. Additional query params * (e.g. requestId for GET /traces) are forwarded. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { if (!(await isAdminRequestAuthorized())) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -87,9 +88,9 @@ export async function POST(req: NextRequest) { { status: 502 } ) } -} +}) -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { if (!(await isAdminRequestAuthorized())) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } @@ -141,4 +142,4 @@ export async function GET(req: NextRequest) { { status: 502 } ) } -} +}) diff --git a/apps/sim/app/api/audit-logs/route.ts b/apps/sim/app/api/audit-logs/route.ts index 3be8c2dc3b6..547d57b118a 100644 --- a/apps/sim/app/api/audit-logs/route.ts +++ b/apps/sim/app/api/audit-logs/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +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 { @@ -13,7 +14,7 @@ const logger = createLogger('AuditLogsAPI') export const dynamic = 'force-dynamic' -export async function GET(request: Request) { +export const GET = withRouteHandler(async (request: Request) => { try { const session = await getSession() if (!session?.user?.id) { @@ -68,4 +69,4 @@ export async function GET(request: Request) { logger.error('Audit logs fetch error', { error: message }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 31005daafcc..22cc107c708 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -3,6 +3,7 @@ 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' @@ -13,7 +14,7 @@ function isBlockedOrganizationMutationPath(path: string): boolean { return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path) } -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/', '') @@ -23,9 +24,9 @@ export async function GET(request: NextRequest) { } return betterAuthGET(request) -} +}) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const url = new URL(request.url) const path = url.pathname.replace('/api/auth/', '') @@ -37,4 +38,4 @@ export async function POST(request: NextRequest) { } return betterAuthPOST(request) -} +}) 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.test.ts b/apps/sim/app/api/auth/forget-password/route.test.ts index 7bffef74e6a..902e7c11d89 100644 --- a/apps/sim/app/api/auth/forget-password/route.test.ts +++ b/apps/sim/app/api/auth/forget-password/route.test.ts @@ -34,6 +34,8 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, })) import { POST } from '@/app/api/auth/forget-password/route' diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index 9db6eef95c9..ef20b11b299 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' 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' @@ -29,7 +30,7 @@ const forgetPasswordSchema = z.object({ ), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json() @@ -89,4 +90,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 d38419f3998..8b4d5cfc894 100644 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment } 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' import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' @@ -13,7 +14,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 { @@ -147,4 +148,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 aac20aca780..3d66dfa3107 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' @@ -13,7 +14,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 { @@ -129,4 +130,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/providers/route.ts b/apps/sim/app/api/auth/providers/route.ts index dadd3fcd083..1b25dd1f8ce 100644 --- a/apps/sim/app/api/auth/providers/route.ts +++ b/apps/sim/app/api/auth/providers/route.ts @@ -1,14 +1,15 @@ import { NextResponse } from 'next/server' import { isRegistrationDisabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' export const dynamic = 'force-dynamic' -export async function GET() { +export const GET = withRouteHandler(async () => { const { githubAvailable, googleAvailable } = await getOAuthProviderStatus() return NextResponse.json({ githubAvailable, googleAvailable, registrationDisabled: isRegistrationDisabled, }) -} +}) diff --git a/apps/sim/app/api/auth/reset-password/route.test.ts b/apps/sim/app/api/auth/reset-password/route.test.ts index 5a8bd79f5e0..c92971a460a 100644 --- a/apps/sim/app/api/auth/reset-password/route.test.ts +++ b/apps/sim/app/api/auth/reset-password/route.test.ts @@ -31,6 +31,8 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, })) import { POST } from '@/app/api/auth/reset-password/route' 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 518a99e1e12..de8ce76b6f9 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -5,6 +5,7 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getScopesForService } from '@/lib/oauth/utils' const logger = createLogger('ShopifyAuthorize') @@ -13,7 +14,7 @@ export const dynamic = 'force-dynamic' const SHOPIFY_SCOPES = getScopesForService('shopify').join(',') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -208,4 +209,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 2eccea62193..45151d1e496 100644 --- a/apps/sim/app/api/auth/socket-token/route.ts +++ b/apps/sim/app/api/auth/socket-token/route.ts @@ -4,10 +4,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' }) } @@ -42,4 +43,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 fbaaf18398f..0305d6b1a72 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -4,10 +4,11 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSOProvidersRoute') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() const { searchParams } = new URL(request.url) @@ -91,4 +92,4 @@ export async function GET(request: NextRequest) { 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 63d4034f306..41e075696aa 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/core/security/input-validation.server' import { REDACTED_MARKER } from '@/lib/core/security/redaction' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SSORegisterRoute') @@ -75,7 +76,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 }) @@ -502,4 +503,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 e1945b1febf..e1ef13aec38 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -3,13 +3,14 @@ import { 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' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') export const dynamic = 'force-dynamic' -export async function GET() { +export const GET = withRouteHandler(async () => { try { const session = await getSession() if (!session?.user?.id) { @@ -41,4 +42,4 @@ export async function GET() { 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 a70da8fadc7..53e05e65f6d 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 { 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() { +export const GET = withRouteHandler(async () => { const baseUrl = getBaseUrl() return new NextResponse( @@ -132,4 +133,4 @@ export async function GET() { }, } ) -} +}) diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 8e2f96aa8ba..abf8c7603a0 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 { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' @@ -13,7 +14,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) { @@ -128,4 +129,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 070b3893133..5afe089ab27 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 }) @@ -78,4 +79,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 2df0d6f2f65..ee0152a6d46 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -7,13 +7,14 @@ import { getSession } from '@/lib/auth' import { getEffectiveBillingStatus } from '@/lib/billing/core/access' import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing' import { getOrganizationBillingData } from '@/lib/billing/core/organization' +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 { @@ -176,4 +177,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 ba3f3c0df55..2cc3594bffc 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -19,6 +19,7 @@ import { isOrgScopedSubscription, } 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') @@ -39,7 +40,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 { @@ -194,4 +195,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 cd3097bda4e..9cfa7e77d47 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -9,6 +9,7 @@ import { checkInternalApiKey } from '@/lib/copilot/request/http' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { type AtomicClaimResult, billingIdempotency } from '@/lib/core/idempotency/service' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('BillingUpdateCostAPI') @@ -28,7 +29,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() let claim: AtomicClaimResult | null = null @@ -201,4 +202,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 6039f407156..433159ff600 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 { getRedisClient } from '@/lib/core/config/redis' import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment' import { getStorageMethod } from '@/lib/core/storage' import { generateRequestId } from '@/lib/core/utils/request' +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,178 +208,186 @@ 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 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, + title: chat.title, + description: chat.description, + customizations: chat.customizations, + authType: chat.authType, + password: chat.password, + outputConfigs: chat.outputConfigs, + }) + .from(chat) + .where( + and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt)) + ) + .limit(1) -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, - title: chat.title, - description: chat.description, - customizations: chat.customizations, - authType: chat.authType, - password: chat.password, - outputConfigs: chat.outputConfigs, - }) - .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}`) + return addCorsHeaders( + createErrorResponse('Too many failed attempts. Please request a new code.', 429), + request + ) + } + return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) + } - 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), + + const response = addCorsHeaders( + createSuccessResponse({ + id: deployment.id, + title: deployment.title, + description: deployment.description, + customizations: deployment.customizations, + authType: deployment.authType, + outputConfigs: deployment.outputConfigs, + }), request ) - } + setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) - 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 response + } catch (error: any) { + if (error instanceof z.ZodError) { return addCorsHeaders( - createErrorResponse('Too many failed attempts. Please request a new code.', 429), + createErrorResponse(error.errors[0]?.message || 'Invalid request', 400), request ) } - return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request) - } - - await deleteOTP(email, deployment.id) - - const response = addCorsHeaders( - createSuccessResponse({ - id: deployment.id, - title: deployment.title, - description: deployment.description, - customizations: deployment.customizations, - authType: deployment.authType, - outputConfigs: deployment.outputConfigs, - }), - request - ) - setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) - - return response - } catch (error: any) { - if (error instanceof z.ZodError) { + 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 8abc55835ed..959f8d27e78 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' +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' @@ -54,79 +55,133 @@ 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, + title: chat.title, + description: chat.description, + customizations: chat.customizations, + 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, - title: chat.title, - description: chat.description, - customizations: chat.customizations, - 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(toChatConfigResponse(deployment)), + 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, @@ -134,183 +189,144 @@ 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(toChatConfigResponse(deployment)), - 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 { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow') - 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 { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow') + 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, - streamConfig: { - selectedOutputs, - isSecureMode: true, - workflowTriggerType: 'chat', - }, - executionId, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - executeWorkflow( - workflowForExecution, - requestId, - workflowInput, - workspaceOwnerId, - { - enabled: true, - selectedOutputs, - isSecureMode: true, - workflowTriggerType: 'chat', - onStream, - onBlockComplete, - skipLoggingComplete: true, - abortSignal, - executionMode: 'stream', - }, - 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, + streamConfig: { + selectedOutputs, + isSecureMode: true, + workflowTriggerType: 'chat', + }, + executionId, + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + executeWorkflow( + workflowForExecution, + requestId, + workflowInput, + workspaceOwnerId, + { + enabled: true, + selectedOutputs, + isSecureMode: true, + workflowTriggerType: 'chat', + onStream, + onBlockComplete, + skipLoggingComplete: true, + abortSignal, + executionMode: 'stream', + }, + 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( @@ -318,80 +334,76 @@ 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) +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => { + const { identifier } = await params + const requestId = generateRequestId() - if (deploymentResult.length === 0) { - logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) - return addCorsHeaders(createErrorResponse('Chat not found', 404), request) - } + 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) - const deployment = deploymentResult[0] + if (deploymentResult.length === 0) { + logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) + return addCorsHeaders(createErrorResponse('Chat not found', 404), request) + } - if (!deployment.isActive) { - logger.warn(`[${requestId}] Chat is not active: ${identifier}`) - return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request) - } + const deployment = deploymentResult[0] - const cookieName = `chat_auth_${deployment.id}` - const authCookie = request.cookies.get(cookieName) + if (!deployment.isActive) { + logger.warn(`[${requestId}] Chat is not active: ${identifier}`) + return addCorsHeaders( + createErrorResponse('This chat is currently unavailable', 403), + request + ) + } - if ( - deployment.authType !== 'public' && - authCookie && - validateAuthToken(authCookie.value, deployment.id, deployment.password) - ) { - return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request) - } + const cookieName = `chat_auth_${deployment.id}` + const authCookie = request.cookies.get(cookieName) - const authResult = await validateChatAuth(requestId, deployment, request) - if (!authResult.authorized) { - logger.info( - `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` - ) + if ( + deployment.authType !== 'public' && + authCookie && + validateAuthToken(authCookie.value, deployment.id, deployment.password) + ) { + return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), 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 + ) + } + + return addCorsHeaders(createSuccessResponse(toChatConfigResponse(deployment)), request) + } 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(toChatConfigResponse(deployment)), 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 dc02fff4d26..3564aa00a79 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 { notifySocketDeploymentChanged, performChatUndeploy } from '@/lib/workflows/orchestration' import { deployWorkflow } from '@/lib/workflows/persistence/utils' import { checkChatAccess } from '@/app/api/chat/utils' @@ -50,258 +51,263 @@ 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})` - ) - await notifySocketDeploymentChanged(existingChat[0].workflowId) - } - - 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(), - } + 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 (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 + // 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})` + ) + await notifySocketDeploymentChanged(existingChat[0].workflowId) } - } - if (encryptedPassword) { - updateData.password = encryptedPassword - } + 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 (allowedEmails) { - updateData.allowedEmails = allowedEmails - } + const updateData: any = { + updatedAt: new Date(), + } - if (outputConfigs) { - updateData.outputConfigs = outputConfigs - } + 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 + } + } - 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 (encryptedPassword) { + updateData.password = encryptedPassword + } - await db.update(chat).set(updateData).where(eq(chat.id, chatId)) + if (allowedEmails) { + updateData.allowedEmails = allowedEmails + } - const updatedIdentifier = identifier || existingChat[0].identifier + if (outputConfigs) { + updateData.outputConfigs = outputConfigs + } - 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}"`, - metadata: { - identifier: updatedIdentifier, - authType: updateData.authType || existingChatRecord.authType, - workflowId: workflowId || existingChatRecord.workflowId, + 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}"`, + metadata: { + identifier: updatedIdentifier, + authType: updateData.authType || existingChatRecord.authType, + workflowId: workflowId || existingChatRecord.workflowId, + chatUrl, + }, + request, + }) + + return createSuccessResponse({ + id: chatId, chatUrl, - }, - 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') + 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 1c1df540132..193f3c745d5 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -6,6 +6,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/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') @@ -13,7 +14,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) { @@ -64,4 +65,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 75e60f2078c..5d0e051d55b 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -6,12 +6,13 @@ import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/request/session' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatAbortAPI') const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000 const STREAM_ABORT_SETTLE_TIMEOUT_MS = 8000 -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { const { userId: authenticatedUserId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -88,4 +89,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 1742d9e7e55..519d038b6a2 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/tasks' +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/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts index 7587f577411..49d8a616bfe 100644 --- a/apps/sim/app/api/copilot/chat/rename/route.ts +++ b/apps/sim/app/api/copilot/chat/rename/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/tasks' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('RenameChatAPI') @@ -15,7 +16,7 @@ const RenameChatSchema = z.object({ title: z.string().min(1).max(200), }) -export async function PATCH(request: NextRequest) { +export const PATCH = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -62,4 +63,4 @@ export async function PATCH(request: NextRequest) { logger.error('Error renaming chat:', error) return NextResponse.json({ success: false, error: 'Failed to rename 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 9335c86d07d..6ed05e3f109 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/http' import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatResourcesAPI') @@ -51,7 +52,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) { @@ -112,9 +113,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) { @@ -157,9 +158,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) { @@ -198,4 +199,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/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 40070b64e0d..c7518ec9095 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' import { taskPubSub } from '@/lib/copilot/tasks' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatStopAPI') @@ -63,7 +64,7 @@ const StopSchema = z.object({ * The chat stream lock is intentionally left alone here; it is released only once * the aborted server stream actually unwinds. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { @@ -145,4 +146,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/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index b88eec2915c..d2501c95c05 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -17,6 +17,7 @@ import { SSE_RESPONSE_HEADERS, } from '@/lib/copilot/request/session' import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const maxDuration = 3600 @@ -79,7 +80,7 @@ function buildResumeTerminalEnvelopes(options: { return envelopes } -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const { userId: authenticatedUserId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() @@ -304,4 +305,4 @@ export async function GET(request: NextRequest) { }) return new Response(stream, { headers: SSE_RESPONSE_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 ee2dfee0bb7..17dafad187d 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -14,6 +14,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotChatUpdateAPI') @@ -55,7 +56,7 @@ const UpdateMessagesSchema = z.object({ .optional(), }) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -133,4 +134,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 b0142c27f7b..512cd129bdc 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/http' import { taskPubSub } from '@/lib/copilot/tasks' +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 b49d21f3841..01661bf4e3b 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/http' 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 c800e519542..b80fad00a08 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/http' +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 3e88151ffe5..9e3f3c9d009 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -23,6 +23,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotConfirmAPI') @@ -117,7 +118,7 @@ async function updateToolCallStatus( * POST /api/copilot/confirm * Accept client tool completion or detach confirmations. */ -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const tracker = createRequestTracker() try { @@ -202,4 +203,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 82d031c9e64..4d570157d60 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/http' 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 175bc995d50..66e124c2402 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/http' +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 73eaeee1979..539ae95f7c3 100644 --- a/apps/sim/app/api/copilot/models/route.ts +++ b/apps/sim/app/api/copilot/models/route.ts @@ -11,6 +11,7 @@ interface AvailableModel { } import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotModelsAPI') @@ -30,7 +31,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 }) @@ -88,4 +89,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 75ed6d096b1..0287429ca1b 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/http' 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 a9318940b91..1e6a5aa6574 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/http' 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 e30918b8212..1c1e64ab0e9 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/http' 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 07671d38ef6..ecb5b8adf18 100644 --- a/apps/sim/app/api/creators/route.ts +++ b/apps/sim/app/api/creators/route.ts @@ -7,6 +7,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 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 752ebc1a9e7..de1a54ae185 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,140 +38,145 @@ 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: { + logger.info('Resent credential set invitation', { + credentialSetId: id, invitationId, - targetEmail: invitation.email, - providerId: result.set.providerId, - credentialSetName: result.set.name, - }, - 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 }) + 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, + providerId: result.set.providerId, + credentialSetName: result.set.name, + }, + 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 874b0b31550..a522cfcf41c 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 { 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('CredentialSetInvite') @@ -43,237 +44,243 @@ 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, + }) - 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, + 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, + }) + } + } catch (emailError) { + logger.error('Error sending invitation email', emailError) + // Don't fail the invitation creation if email fails } - } 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, - }) - 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: { + logger.info('Created credential set invitation', { + credentialSetId: id, invitationId: invitation.id, - targetEmail: email || undefined, - providerId: result.set.providerId, - credentialSetName: result.set.name, - }, - 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 }) + 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: { + invitationId: invitation.id, + targetEmail: email || undefined, + providerId: result.set.providerId, + credentialSetName: result.set.name, + }, + 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 49959b85334..ca3c1894903 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 { type NextRequest, NextResponse } from 'next/server' 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' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' const logger = createLogger('CredentialSetMembers') @@ -36,179 +37,185 @@ 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 }) + } - logger.info('Removed member from credential set', { - credentialSetId: id, - memberId, - userId: session.user.id, - }) + 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) - 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: { + // 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, - memberUserId: memberToRemove.userId, - targetEmail: memberToRemove.email ?? undefined, - providerId: result.set.providerId, - }, - request: req, - }) + userId: session.user.id, + }) - 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 }) + 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: { + memberId, + memberUserId: memberToRemove.userId, + targetEmail: memberToRemove.email ?? undefined, + providerId: result.set.providerId, + }, + 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 }) + } } -} +) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index d522cf9c3df..47e9c44d2d1 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,175 +45,185 @@ 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}"`, + metadata: { + organizationId: result.set.organizationId, + providerId: result.set.providerId, + updatedFields: Object.keys(updates).filter( + (k) => updates[k as keyof typeof updates] !== undefined + ), + }, + 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}"`, - metadata: { - organizationId: result.set.organizationId, - providerId: result.set.providerId, - updatedFields: Object.keys(updates).filter( - (k) => updates[k as keyof typeof updates] !== undefined - ), - }, - 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}"`, - metadata: { organizationId: result.set.organizationId, providerId: result.set.providerId }, - 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}"`, + metadata: { organizationId: result.set.organizationId, providerId: result.set.providerId }, + 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 a72b879128b..8c248db611c 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 { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +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 }) - } +export const GET = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ token: string }> }) => { + const { token } = await params - 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 }) - } - - 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,57 +151,68 @@ 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', { + logger.info('Accepted credential set invitation', { + invitationId: invitation.id, credentialSetId: invitation.credentialSetId, - ...syncResult, + 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, + credentialSetId: invitation.credentialSetId, + providerId: invitation.providerId, + credentialSetName: invitation.credentialSetName, + }, + 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, + return NextResponse.json({ + success: true, credentialSetId: invitation.credentialSetId, providerId: invitation.providerId, - credentialSetName: invitation.credentialSetName, - }, - 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 }) + }) + } 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 ec6ba4b41d9..e198790b62d 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 { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +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) { @@ -126,4 +127,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 bfd73c78f50..55c06f8686b 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -8,6 +8,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('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) { @@ -191,4 +192,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 220f12b04b0..2a9970e1bfa 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 { 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' 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 3ed5d2b9164..6b265e4636c 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -8,6 +8,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 { getCredentialActorContext } from '@/lib/credentials/access' import { deleteWorkspaceEnvCredentials, @@ -64,270 +65,300 @@ 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 }) - } -} + const { id } = await params -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 + 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 }) + } + + const updates: Record = {} + + if (parseResult.data.description !== undefined) { + updates.description = parseResult.data.description ?? null + } - 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 ( + parseResult.data.displayName !== undefined && + (access.credential.type === 'oauth' || access.credential.type === 'service_account') + ) { + updates.displayName = parseResult.data.displayName } + if ( - parsed.type !== 'service_account' || - typeof parsed.client_email !== 'string' || - typeof parsed.private_key !== 'string' || - typeof parsed.project_id !== 'string' + parseResult.data.serviceAccountJson !== undefined && + access.credential.type === 'service_account' ) { - return NextResponse.json({ error: 'Invalid service account JSON key' }, { status: 400 }) + 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 } - 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 (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)) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), - }, - request, - }) - - 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 }) - } -} - -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 }) - } - - 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, - 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 } } - ) + updates.updatedAt = new Date() + await db.update(credential).set(updates).where(eq(credential.id, id)) recordAudit({ workspaceId: access.credential.workspaceId, actorId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, + action: AuditAction.CREDENTIAL_UPDATED, resourceType: AuditResourceType.CREDENTIAL, resourceId: id, resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + credentialType: access.credential.type, + updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), + }, request, }) - return NextResponse.json({ success: true }, { status: 200 }) + 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 }) + } + } +) + +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_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, + 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 }) + } + + 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, + envKeys: Object.keys(current), }) - .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] + + 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 } } + ) + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: id, + resourceName: access.credential.displayName, + description: `Deleted personal env credential "${access.credential.envKey}"`, + metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, + request, + }) + + 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 deleteWorkspaceEnvCredentials({ workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), + removedKeys: [access.credential.envKey], }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, + + 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 } } + ) + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: id, + resourceName: access.credential.displayName, + description: `Deleted workspace env credential "${access.credential.envKey}"`, + metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, + request, }) - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) + 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 } } @@ -342,47 +373,18 @@ export async function DELETE( resourceType: AuditResourceType.CREDENTIAL, resourceId: id, resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, + description: `Deleted ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + credentialType: access.credential.type, + providerId: access.credential.providerId, + }, request, }) 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 } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: id, - resourceName: access.credential.displayName, - description: `Deleted ${access.credential.type} credential "${access.credential.displayName}"`, - metadata: { - credentialType: access.credential.type, - providerId: access.credential.providerId, - }, - request, - }) - - 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 504a6f0d334..8fb66fea56f 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -6,6 +6,7 @@ import { and, eq, lt } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +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 3ed44d4a863..465ae5340ea 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -9,6 +9,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' +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' @@ -229,7 +230,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() @@ -345,9 +346,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() @@ -660,4 +661,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-soft-deletes/route.ts b/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts index 4b3aef9674e..1df6df035e3 100644 --- a/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts +++ b/apps/sim/app/api/cron/cleanup-soft-deletes/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('SoftDeleteCleanupAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const authError = verifyCronAuth(request, 'soft-delete cleanup') if (authError) return authError @@ -21,4 +22,4 @@ export async function GET(request: NextRequest) { logger.error('Failed to dispatch soft-delete cleanup jobs:', { error }) return NextResponse.json({ error: 'Failed to dispatch soft-delete cleanup' }, { 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 4983927241f..52c9420916c 100644 --- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts +++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts @@ -7,6 +7,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') @@ -14,7 +15,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) { @@ -183,4 +184,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/cleanup-tasks/route.ts b/apps/sim/app/api/cron/cleanup-tasks/route.ts index b53837e9899..75b31492a19 100644 --- a/apps/sim/app/api/cron/cleanup-tasks/route.ts +++ b/apps/sim/app/api/cron/cleanup-tasks/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('TaskCleanupAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const authError = verifyCronAuth(request, 'task cleanup') if (authError) return authError @@ -21,4 +22,4 @@ export async function GET(request: NextRequest) { logger.error('Failed to dispatch task cleanup jobs:', { error }) return NextResponse.json({ error: 'Failed to dispatch task cleanup' }, { 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 39659a6d3d8..9c0aca941ea 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -9,6 +9,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' +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 { @@ -94,9 +95,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 { @@ -136,4 +137,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 15b0ad02833..a9126e5bb29 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 { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' @@ -108,68 +109,67 @@ 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 isPublicByKeyPrefix = + cloudKey.startsWith('profile-pictures/') || + cloudKey.startsWith('og-images/') || + cloudKey.startsWith('workspace-logos/') + + if (isPublicByKeyPrefix) { + const context = inferContextFromKey(cloudKey) + logger.info(`Serving public ${context}:`, { cloudKey }) + if (isUsingCloudStorage() || isCloudPath) { + return await handleCloudProxyPublic(cloudKey, context) + } + return await handleLocalFilePublic(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 raw = request.nextUrl.searchParams.get('raw') === '1' - const isPublicByKeyPrefix = - cloudKey.startsWith('profile-pictures/') || - cloudKey.startsWith('og-images/') || - cloudKey.startsWith('workspace-logos/') + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (isPublicByKeyPrefix) { - const context = inferContextFromKey(cloudKey) - logger.info(`Serving public ${context}:`, { cloudKey }) - if (isUsingCloudStorage() || isCloudPath) { - return await handleCloudProxyPublic(cloudKey, context) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized file access attempt', { + path, + error: authResult.error || 'Missing userId', + }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - return await handleLocalFilePublic(fullPath) - } - - const raw = request.nextUrl.searchParams.get('raw') === '1' - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + const userId = authResult.userId - 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 + if (isUsingCloudStorage()) { + return await handleCloudProxy(cloudKey, userId, raw, request.signal) + } - if (isUsingCloudStorage()) { - return await handleCloudProxy(cloudKey, userId, raw, request.signal) - } + return await handleLocalFile(cloudKey, userId, raw, request.signal) + } catch (error) { + logger.error('Error serving file:', error) - return await handleLocalFile(cloudKey, userId, raw, request.signal) - } 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 f65467e016f..9ea1c53d871 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -4,6 +4,7 @@ import { sanitizeFileName } from '@/executor/constants' import '@/lib/uploads/core/setup.server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -42,7 +43,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) { @@ -415,8 +416,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 9225390746d..7cdc446b8d5 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 { 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 { 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.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index 54411b99d9c..04f697e74a5 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -40,6 +40,8 @@ const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermiss vi.mock('@/lib/audit/log', () => auditMock) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@sim/db', () => ({ 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.test.ts b/apps/sim/app/api/folders/route.test.ts index 7f893b06b37..d3a3a173fa6 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -37,6 +37,8 @@ vi.mock('drizzle-orm', () => ({ })) vi.mock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), + runWithRequestContext: (_ctx: unknown, fn: () => T): T => fn(), + getRequestContext: () => undefined, })) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 4b0612fcb98..69e8c42921c 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -7,6 +7,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 { 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) { @@ -191,4 +192,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 8cb11dabc19..62b2faa8bb2 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' @@ -51,75 +52,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, @@ -128,147 +178,113 @@ 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 + ) } - const stream = await createStreamingResponse({ - requestId, - streamConfig: { - selectedOutputs: [], - isSecureMode: true, - workflowTriggerType: 'api', - }, - executionId, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - executeWorkflow( - workflowForExecution, - requestId, - workflowInput, - workspaceOwnerId, - { - enabled: true, - selectedOutputs: [], - isSecureMode: true, - workflowTriggerType: 'api', - onStream, - onBlockComplete, - skipLoggingComplete: true, - abortSignal, - executionMode: 'sync', - }, - executionId - ), - }) - - const reader = stream.getReader() try { - while (!(await reader.read()).done) { - /* drain to let the workflow run to completion */ + const workflowForExecution = { + id: deployment.workflowId, + userId: deployment.userId, + workspaceId, + isDeployed: workflowRecord?.isDeployed ?? false, + variables: (workflowRecord?.variables ?? {}) as Record, } - } finally { - reader.releaseLock() - } - logger.info(`[${requestId}] Form submission successful for ${identifier}`) + // Pass form data as the workflow input + const workflowInput = { + input: formData, + ...formData, // Spread form fields at top level for convenience + } - // Return success with customizations for thank you screen - const customizations = deployment.customizations as Record | null - return addCorsHeaders( - createSuccessResponse({ - success: true, + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: [], + isSecureMode: true, + workflowTriggerType: 'api', + }, executionId, - thankYouTitle: customizations?.thankYouTitle || 'Thank you!', - thankYouMessage: - customizations?.thankYouMessage || 'Your response has been submitted successfully.', - }), - request - ) + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + executeWorkflow( + workflowForExecution, + requestId, + workflowInput, + workspaceOwnerId, + { + enabled: true, + selectedOutputs: [], + isSecureMode: true, + workflowTriggerType: 'api', + onStream, + onBlockComplete, + skipLoggingComplete: true, + abortSignal, + executionMode: 'sync', + }, + executionId + ), + }) + + const reader = stream.getReader() + try { + while (!(await reader.read()).done) { + /* drain to let the workflow run to completion */ + } + } finally { + reader.releaseLock() + } + + 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 + ) + } 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( @@ -276,64 +292,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, @@ -346,54 +396,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 a57a7c937bb..a8df0decc93 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,213 +64,216 @@ 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: (title || formRecord.title) ?? undefined, + description: `Updated form "${title || formRecord.title}"`, + metadata: { + identifier: identifier || formRecord.identifier, + workflowId: formRecord.workflowId, + authType: authType || formRecord.authType, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, + 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) + } + } +) - if (password) { - const { encrypted } = await encryptSecret(password) - updateData.password = encrypted - } else if (authType && authType !== 'password') { - updateData.password = null +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + try { + const session = await getSession() + + if (!session) { + return createErrorResponse('Unauthorized', 401) } - await db.update(form).set(updateData).where(eq(form.id, id)) + const { id } = await params - logger.info(`Form ${id} updated successfully`) + 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_UPDATED, + action: AuditAction.FORM_DELETED, resourceType: AuditResourceType.FORM, resourceId: id, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - resourceName: (title || formRecord.title) ?? undefined, - description: `Updated form "${title || formRecord.title}"`, - metadata: { - identifier: identifier || formRecord.identifier, - workflowId: formRecord.workflowId, - authType: authType || formRecord.authType, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, + resourceName: formRecord.title ?? undefined, + description: `Deleted form "${formRecord.title}"`, + metadata: { identifier: formRecord.identifier, workflowId: formRecord.workflowId }, 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}"`, - metadata: { identifier: formRecord.identifier, workflowId: formRecord.workflowId }, - 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 500e5b4bb47..7b1ded808be 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -10,6 +10,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 { notifySocketDeploymentChanged } from '@/lib/workflows/orchestration' import { deployWorkflow } from '@/lib/workflows/persistence/utils' import { @@ -66,7 +67,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() @@ -84,9 +85,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() @@ -231,4 +232,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 e172f31d771..63dfbff136b 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/copilot/request/tools/files' 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, executeShellInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' @@ -687,7 +688,7 @@ async function maybeExportSandboxFileToWorkspace(args: { }) } -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() const startTime = Date.now() let stdout = '' @@ -1201,4 +1202,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/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts index bbb5ca9835f..928bcf002b0 100644 --- a/apps/sim/app/api/invitations/[id]/accept/route.ts +++ b/apps/sim/app/api/invitations/[id]/accept/route.ts @@ -3,82 +3,85 @@ 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 { acceptInvitation } from '@/lib/invitations/core' const logger = createLogger('InvitationAcceptAPI') const bodySchema = z.object({ token: z.string().min(1).optional() }) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - if (!session?.user?.id || !session.user.email) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const body = await request.json().catch(() => ({})) - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const body = await request.json().catch(() => ({})) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } - const result = await acceptInvitation({ - userId: session.user.id, - userEmail: session.user.email, - invitationId: id, - token: parsed.data.token ?? null, - }) + const result = await acceptInvitation({ + userId: session.user.id, + userEmail: session.user.email, + invitationId: id, + token: parsed.data.token ?? null, + }) - if (!result.success) { - const statusMap: Record = { - 'not-found': 404, - 'invalid-token': 400, - 'already-processed': 400, - expired: 400, - 'email-mismatch': 403, - 'already-in-organization': 409, - 'no-seats-available': 400, - 'server-error': 500, + if (!result.success) { + const statusMap: Record = { + 'not-found': 404, + 'invalid-token': 400, + 'already-processed': 400, + expired: 400, + 'email-mismatch': 403, + 'already-in-organization': 409, + 'no-seats-available': 400, + 'server-error': 500, + } + const status = statusMap[result.kind] ?? 500 + logger.warn('Invitation accept rejected', { invitationId: id, reason: result.kind }) + return NextResponse.json({ error: result.kind }, { status }) } - const status = statusMap[result.kind] ?? 500 - logger.warn('Invitation accept rejected', { invitationId: id, reason: result.kind }) - return NextResponse.json({ error: result.kind }, { status }) - } - const inv = result.invitation + const inv = result.invitation - recordAudit({ - workspaceId: result.acceptedWorkspaceIds[0] ?? null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: - inv.kind === 'workspace' - ? AuditAction.INVITATION_ACCEPTED - : AuditAction.ORG_INVITATION_ACCEPTED, - resourceType: - inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, - resourceId: inv.organizationId ?? result.acceptedWorkspaceIds[0] ?? inv.id, - description: `Accepted ${inv.kind} invitation for ${inv.email}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: inv.role, - kind: inv.kind, - workspaceIds: result.acceptedWorkspaceIds, - }, - request, - }) + recordAudit({ + workspaceId: result.acceptedWorkspaceIds[0] ?? null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_ACCEPTED + : AuditAction.ORG_INVITATION_ACCEPTED, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? result.acceptedWorkspaceIds[0] ?? inv.id, + description: `Accepted ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + workspaceIds: result.acceptedWorkspaceIds, + }, + request, + }) - return NextResponse.json({ - success: true, - redirectPath: result.redirectPath, - invitation: { - id: inv.id, - kind: inv.kind, - organizationId: inv.organizationId, - acceptedWorkspaceIds: result.acceptedWorkspaceIds, - }, - }) -} + return NextResponse.json({ + success: true, + redirectPath: result.redirectPath, + invitation: { + id: inv.id, + kind: inv.kind, + organizationId: inv.organizationId, + acceptedWorkspaceIds: result.acceptedWorkspaceIds, + }, + }) + } +) diff --git a/apps/sim/app/api/invitations/[id]/reject/route.ts b/apps/sim/app/api/invitations/[id]/reject/route.ts index 07e3c875217..bc4be85a7be 100644 --- a/apps/sim/app/api/invitations/[id]/reject/route.ts +++ b/apps/sim/app/api/invitations/[id]/reject/route.ts @@ -3,68 +3,71 @@ 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 { rejectInvitation } from '@/lib/invitations/core' const logger = createLogger('InvitationRejectAPI') const bodySchema = z.object({ token: z.string().min(1).optional() }) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() +export const POST = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - if (!session?.user?.id || !session.user.email) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const body = await request.json().catch(() => ({})) - const parsed = bodySchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } + const body = await request.json().catch(() => ({})) + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } - const result = await rejectInvitation({ - userId: session.user.id, - userEmail: session.user.email, - invitationId: id, - token: parsed.data.token ?? null, - }) + const result = await rejectInvitation({ + userId: session.user.id, + userEmail: session.user.email, + invitationId: id, + token: parsed.data.token ?? null, + }) - if (!result.success) { - const statusMap: Record = { - 'not-found': 404, - 'invalid-token': 400, - 'already-processed': 400, - expired: 400, - 'email-mismatch': 403, + if (!result.success) { + const statusMap: Record = { + 'not-found': 404, + 'invalid-token': 400, + 'already-processed': 400, + expired: 400, + 'email-mismatch': 403, + } + const status = statusMap[result.kind] ?? 500 + logger.warn('Invitation reject rejected', { invitationId: id, reason: result.kind }) + return NextResponse.json({ error: result.kind }, { status }) } - const status = statusMap[result.kind] ?? 500 - logger.warn('Invitation reject rejected', { invitationId: id, reason: result.kind }) - return NextResponse.json({ error: result.kind }, { status }) - } - const inv = result.invitation - recordAudit({ - workspaceId: null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: - inv.kind === 'workspace' - ? AuditAction.INVITATION_REJECTED - : AuditAction.ORG_INVITATION_REJECTED, - resourceType: - inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, - resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, - description: `Rejected ${inv.kind} invitation for ${inv.email}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: inv.role, - kind: inv.kind, - }, - request, - }) + const inv = result.invitation + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_REJECTED + : AuditAction.ORG_INVITATION_REJECTED, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, + description: `Rejected ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + request, + }) - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + } +) diff --git a/apps/sim/app/api/invitations/[id]/resend/route.ts b/apps/sim/app/api/invitations/[id]/resend/route.ts index 14388ebe86e..28d0dc11937 100644 --- a/apps/sim/app/api/invitations/[id]/resend/route.ts +++ b/apps/sim/app/api/invitations/[id]/resend/route.ts @@ -9,6 +9,7 @@ import { getOrganizationSubscription } from '@/lib/billing/core/billing' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { isEnterprise, isTeam } from '@/lib/billing/plan-helpers' import { hasUsableSubscriptionStatus } from '@/lib/billing/subscriptions/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getInvitationById } from '@/lib/invitations/core' import { persistInvitationResend, @@ -20,137 +21,139 @@ import { getWorkspaceInvitePolicy } from '@/lib/workspaces/policy' const logger = createLogger('InvitationResendAPI') -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const session = await getSession() +export const POST = 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 }) - } - - try { - const inv = await getInvitationById(id) - if (!inv) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - if (inv.status !== 'pending') { - return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - let canResend = false - if (inv.organizationId) { - canResend = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) - } - if (!canResend && inv.grants.length > 0) { - const adminChecks = await Promise.all( - inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) - ) - canResend = adminChecks.some(Boolean) - } - if (!canResend) { - return NextResponse.json( - { error: 'Only an organization or workspace admin can resend this invitation' }, - { status: 403 } - ) - } + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + if (inv.status !== 'pending') { + return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) + } - for (const grant of inv.grants) { - const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId) - if (!workspaceDetails) { - return NextResponse.json( - { error: 'Invitation references a workspace that no longer exists' }, - { status: 409 } - ) + let canResend = false + if (inv.organizationId) { + canResend = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) } - const policy = await getWorkspaceInvitePolicy(workspaceDetails) - if (!policy.allowed) { - return NextResponse.json( - { - error: policy.reason ?? 'Invites are no longer allowed on this workspace', - upgradeRequired: policy.upgradeRequired, - }, - { status: 403 } + if (!canResend && inv.grants.length > 0) { + const adminChecks = await Promise.all( + inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) ) + canResend = adminChecks.some(Boolean) } - } - - if (inv.kind === 'organization' && inv.grants.length === 0 && inv.organizationId) { - const orgSubscription = await getOrganizationSubscription(inv.organizationId) - const orgOnTeamOrEnterprise = - !!orgSubscription && - hasUsableSubscriptionStatus(orgSubscription.status) && - (isTeam(orgSubscription.plan) || isEnterprise(orgSubscription.plan)) - if (!orgOnTeamOrEnterprise) { + if (!canResend) { return NextResponse.json( - { - error: 'Invites are no longer allowed on this organization', - upgradeRequired: true, - }, + { error: 'Only an organization or workspace admin can resend this invitation' }, { status: 403 } ) } - } - const { tokenForEmail, nextToken, nextExpiresAt } = await prepareInvitationResend({ - invitationId: id, - rotateToken: true, - currentToken: inv.token, - }) - - const [inviterRow] = await db - .select({ name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) + for (const grant of inv.grants) { + const workspaceDetails = await getWorkspaceWithOwner(grant.workspaceId) + if (!workspaceDetails) { + return NextResponse.json( + { error: 'Invitation references a workspace that no longer exists' }, + { status: 409 } + ) + } + const policy = await getWorkspaceInvitePolicy(workspaceDetails) + if (!policy.allowed) { + return NextResponse.json( + { + error: policy.reason ?? 'Invites are no longer allowed on this workspace', + upgradeRequired: policy.upgradeRequired, + }, + { status: 403 } + ) + } + } - const emailResult = await sendInvitationEmail({ - invitationId: inv.id, - token: tokenForEmail, - kind: inv.kind, - email: inv.email, - inviterName: inviterRow?.name || inviterRow?.email || 'A user', - organizationId: inv.organizationId, - organizationRole: (inv.role as 'admin' | 'member') || 'member', - grants: inv.grants.map((grant) => ({ - workspaceId: grant.workspaceId, - permission: grant.permission, - })), - }) + if (inv.kind === 'organization' && inv.grants.length === 0 && inv.organizationId) { + const orgSubscription = await getOrganizationSubscription(inv.organizationId) + const orgOnTeamOrEnterprise = + !!orgSubscription && + hasUsableSubscriptionStatus(orgSubscription.status) && + (isTeam(orgSubscription.plan) || isEnterprise(orgSubscription.plan)) + if (!orgOnTeamOrEnterprise) { + return NextResponse.json( + { + error: 'Invites are no longer allowed on this organization', + upgradeRequired: true, + }, + { status: 403 } + ) + } + } - if (!emailResult.success) { - return NextResponse.json( - { error: emailResult.error || 'Failed to send invitation email' }, - { status: 502 } - ) - } + const { tokenForEmail, nextToken, nextExpiresAt } = await prepareInvitationResend({ + invitationId: id, + rotateToken: true, + currentToken: inv.token, + }) - await persistInvitationResend({ invitationId: id, nextToken, nextExpiresAt }) + const [inviterRow] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) - recordAudit({ - workspaceId: inv.grants[0]?.workspaceId ?? null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: - inv.kind === 'workspace' - ? AuditAction.INVITATION_RESENT - : AuditAction.ORG_INVITATION_RESENT, - resourceType: - inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, - resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, - description: `Resent ${inv.kind} invitation to ${inv.email}`, - metadata: { + const emailResult = await sendInvitationEmail({ invitationId: inv.id, - targetEmail: inv.email, - targetRole: inv.role, + token: tokenForEmail, kind: inv.kind, - }, - request, - }) + email: inv.email, + inviterName: inviterRow?.name || inviterRow?.email || 'A user', + organizationId: inv.organizationId, + organizationRole: (inv.role as 'admin' | 'member') || 'member', + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + permission: grant.permission, + })), + }) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to resend invitation', { invitationId: id, error }) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + if (!emailResult.success) { + return NextResponse.json( + { error: emailResult.error || 'Failed to send invitation email' }, + { status: 502 } + ) + } + + await persistInvitationResend({ invitationId: id, nextToken, nextExpiresAt }) + + recordAudit({ + workspaceId: inv.grants[0]?.workspaceId ?? null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_RESENT + : AuditAction.ORG_INVITATION_RESENT, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? inv.id, + description: `Resent ${inv.kind} invitation to ${inv.email}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + request, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to resend invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index fe12f9398b0..b6b15249baf 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -7,69 +7,72 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelInvitation, getInvitationById, normalizeEmail } from '@/lib/invitations/core' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InvitationsAPI') -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 }) - } - - try { - const inv = await getInvitationById(id) - if (!inv) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const token = request.nextUrl.searchParams.get('token') - const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email) - const tokenMatches = !!token && token === inv.token + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } - let hasAdminView = false - if (inv.organizationId) { - hasAdminView = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) - } - if (!hasAdminView && inv.grants.length > 0) { - const adminChecks = await Promise.all( - inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) - ) - hasAdminView = adminChecks.some(Boolean) - } + const token = request.nextUrl.searchParams.get('token') + const isInvitee = normalizeEmail(session.user.email || '') === normalizeEmail(inv.email) + const tokenMatches = !!token && token === inv.token - if (!isInvitee && !tokenMatches && !hasAdminView) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } + let hasAdminView = false + if (inv.organizationId) { + hasAdminView = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) + } + if (!hasAdminView && inv.grants.length > 0) { + const adminChecks = await Promise.all( + inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) + ) + hasAdminView = adminChecks.some(Boolean) + } + + if (!isInvitee && !tokenMatches && !hasAdminView) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } - return NextResponse.json({ - invitation: { - id: inv.id, - kind: inv.kind, - email: inv.email, - organizationId: inv.organizationId, - organizationName: inv.organizationName, - role: inv.role, - status: inv.status, - expiresAt: inv.expiresAt, - createdAt: inv.createdAt, - inviterName: inv.inviterName, - inviterEmail: inv.inviterEmail, - grants: inv.grants.map((grant) => ({ - workspaceId: grant.workspaceId, - workspaceName: grant.workspaceName, - permission: grant.permission, - })), - }, - }) - } catch (error) { - logger.error('Failed to fetch invitation', { invitationId: id, error }) - return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) + return NextResponse.json({ + invitation: { + id: inv.id, + kind: inv.kind, + email: inv.email, + organizationId: inv.organizationId, + organizationName: inv.organizationName, + role: inv.role, + status: inv.status, + expiresAt: inv.expiresAt, + createdAt: inv.createdAt, + inviterName: inv.inviterName, + inviterEmail: inv.inviterEmail, + grants: inv.grants.map((grant) => ({ + workspaceId: grant.workspaceId, + workspaceName: grant.workspaceName, + permission: grant.permission, + })), + }, + }) + } catch (error) { + logger.error('Failed to fetch invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 }) + } } -} +) const patchSchema = z .object({ @@ -87,184 +90,185 @@ const patchSchema = z message: 'Provide a role or at least one grant update', }) -export async function PATCH(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 }) - } - - try { - const inv = await getInvitationById(id) - if (!inv) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (inv.status !== 'pending') { - return NextResponse.json({ error: 'Can only modify pending invitations' }, { status: 400 }) - } +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - const body = await request.json().catch(() => ({})) - const parsed = patchSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message || 'Invalid request body' }, - { status: 400 } - ) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { role, grants } = parsed.data - - if (role !== undefined) { - if (!inv.organizationId) { - return NextResponse.json( - { error: 'Role updates are only valid on organization-scoped invitations' }, - { status: 400 } - ) + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) } - if (!(await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId))) { - return NextResponse.json( - { error: 'Only an organization owner or admin can change invitation roles' }, - { status: 403 } - ) + + if (inv.status !== 'pending') { + return NextResponse.json({ error: 'Can only modify pending invitations' }, { status: 400 }) } - } - const grantsToApply = grants ?? [] - for (const update of grantsToApply) { - const belongsToInvite = inv.grants.some((g) => g.workspaceId === update.workspaceId) - if (!belongsToInvite) { + const body = await request.json().catch(() => ({})) + const parsed = patchSchema.safeParse(body) + if (!parsed.success) { return NextResponse.json( - { error: `Invitation does not grant access to workspace ${update.workspaceId}` }, + { error: parsed.error.errors[0]?.message || 'Invalid request body' }, { status: 400 } ) } - if (!(await hasWorkspaceAdminAccess(session.user.id, update.workspaceId))) { - return NextResponse.json( - { error: 'Workspace admin access required to change grant permissions' }, - { status: 403 } - ) - } - } - await db.transaction(async (tx) => { - if (role !== undefined && role !== inv.role) { - await tx - .update(invitation) - .set({ role, updatedAt: new Date() }) - .where(eq(invitation.id, id)) + const { role, grants } = parsed.data + + if (role !== undefined) { + if (!inv.organizationId) { + return NextResponse.json( + { error: 'Role updates are only valid on organization-scoped invitations' }, + { status: 400 } + ) + } + if (!(await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId))) { + return NextResponse.json( + { error: 'Only an organization owner or admin can change invitation roles' }, + { status: 403 } + ) + } } + + const grantsToApply = grants ?? [] for (const update of grantsToApply) { - await tx - .update(invitationWorkspaceGrant) - .set({ permission: update.permission, updatedAt: new Date() }) - .where( - and( - eq(invitationWorkspaceGrant.invitationId, id), - eq(invitationWorkspaceGrant.workspaceId, update.workspaceId) - ) + const belongsToInvite = inv.grants.some((g) => g.workspaceId === update.workspaceId) + if (!belongsToInvite) { + return NextResponse.json( + { error: `Invitation does not grant access to workspace ${update.workspaceId}` }, + { status: 400 } + ) + } + if (!(await hasWorkspaceAdminAccess(session.user.id, update.workspaceId))) { + return NextResponse.json( + { error: 'Workspace admin access required to change grant permissions' }, + { status: 403 } ) + } } - }) - const isOrgScoped = inv.kind === 'organization' - const primaryWorkspaceId = inv.grants[0]?.workspaceId ?? null - recordAudit({ - workspaceId: isOrgScoped ? null : primaryWorkspaceId, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: isOrgScoped ? AuditAction.ORG_INVITATION_UPDATED : AuditAction.INVITATION_UPDATED, - resourceType: isOrgScoped ? AuditResourceType.ORGANIZATION : AuditResourceType.WORKSPACE, - resourceId: isOrgScoped ? (inv.organizationId ?? inv.id) : (primaryWorkspaceId ?? inv.id), - description: `Updated ${inv.kind} invitation for ${inv.email}`, - metadata: { - invitationId: id, - targetEmail: inv.email, - kind: inv.kind, - roleUpdate: role ?? null, - grantUpdates: grantsToApply, - }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to update invitation', { invitationId: id, error }) - return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) - } -} + await db.transaction(async (tx) => { + if (role !== undefined && role !== inv.role) { + await tx + .update(invitation) + .set({ role, updatedAt: new Date() }) + .where(eq(invitation.id, id)) + } + for (const update of grantsToApply) { + await tx + .update(invitationWorkspaceGrant) + .set({ permission: update.permission, updatedAt: new Date() }) + .where( + and( + eq(invitationWorkspaceGrant.invitationId, id), + eq(invitationWorkspaceGrant.workspaceId, update.workspaceId) + ) + ) + } + }) -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const session = await getSession() + const isOrgScoped = inv.kind === 'organization' + const primaryWorkspaceId = inv.grants[0]?.workspaceId ?? null + recordAudit({ + workspaceId: isOrgScoped ? null : primaryWorkspaceId, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: isOrgScoped ? AuditAction.ORG_INVITATION_UPDATED : AuditAction.INVITATION_UPDATED, + resourceType: isOrgScoped ? AuditResourceType.ORGANIZATION : AuditResourceType.WORKSPACE, + resourceId: isOrgScoped ? (inv.organizationId ?? inv.id) : (primaryWorkspaceId ?? inv.id), + description: `Updated ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: id, + targetEmail: inv.email, + kind: inv.kind, + roleUpdate: role ?? null, + grantUpdates: grantsToApply, + }, + request, + }) - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to update invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 }) + } } +) - try { - const inv = await getInvitationById(id) - if (!inv) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - let canCancel = false - if (inv.organizationId) { - canCancel = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) - } - if (!canCancel && inv.grants.length > 0) { - const adminChecks = await Promise.all( - inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) - ) - canCancel = adminChecks.some(Boolean) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (!canCancel) { - return NextResponse.json( - { error: 'Only an organization or workspace admin can cancel this invitation' }, - { status: 403 } - ) - } + try { + const inv = await getInvitationById(id) + if (!inv) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } - if (inv.status !== 'pending') { - return NextResponse.json({ error: 'Can only cancel pending invitations' }, { status: 400 }) - } + let canCancel = false + if (inv.organizationId) { + canCancel = await isOrganizationOwnerOrAdmin(session.user.id, inv.organizationId) + } + if (!canCancel && inv.grants.length > 0) { + const adminChecks = await Promise.all( + inv.grants.map((grant) => hasWorkspaceAdminAccess(session.user.id, grant.workspaceId)) + ) + canCancel = adminChecks.some(Boolean) + } - const cancelled = await cancelInvitation(id) - if (!cancelled) { - return NextResponse.json({ error: 'Invitation not cancellable' }, { status: 400 }) - } + if (!canCancel) { + return NextResponse.json( + { error: 'Only an organization or workspace admin can cancel this invitation' }, + { status: 403 } + ) + } + + if (inv.status !== 'pending') { + return NextResponse.json({ error: 'Can only cancel pending invitations' }, { status: 400 }) + } + + const cancelled = await cancelInvitation(id) + if (!cancelled) { + return NextResponse.json({ error: 'Invitation not cancellable' }, { status: 400 }) + } - recordAudit({ - workspaceId: inv.grants[0]?.workspaceId ?? null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: - inv.kind === 'workspace' - ? AuditAction.INVITATION_REVOKED - : AuditAction.ORG_INVITATION_REVOKED, - resourceType: - inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, - resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? id, - description: `Cancelled ${inv.kind} invitation for ${inv.email}`, - metadata: { - invitationId: id, - targetEmail: inv.email, - targetRole: inv.role, - kind: inv.kind, - }, - request, - }) + recordAudit({ + workspaceId: inv.grants[0]?.workspaceId ?? null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: + inv.kind === 'workspace' + ? AuditAction.INVITATION_REVOKED + : AuditAction.ORG_INVITATION_REVOKED, + resourceType: + inv.kind === 'workspace' ? AuditResourceType.WORKSPACE : AuditResourceType.ORGANIZATION, + resourceId: inv.organizationId ?? inv.grants[0]?.workspaceId ?? id, + description: `Cancelled ${inv.kind} invitation for ${inv.email}`, + metadata: { + invitationId: id, + targetEmail: inv.email, + targetRole: inv.role, + kind: inv.kind, + }, + request, + }) - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to cancel invitation', { invitationId: id, error }) - return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Failed to cancel invitation', { invitationId: id, error }) + return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 65d9cba2dcf..3275d56ae80 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -4,80 +4,80 @@ 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 { 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 jobQueue = await getJobQueue() - const job = await jobQueue.getJob(taskId) + const jobQueue = await getJobQueue() + const job = await jobQueue.getJob(taskId) - if (!job) { - return createErrorResponse('Task not found', 404) - } + if (!job) { + return createErrorResponse('Task not found', 404) + } - const metadataToCheck = job.metadata + const metadataToCheck = 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 response: Record = { + success: true, + taskId, + status: job.status, + metadata: job.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 response: Record = { - success: true, - taskId, - status: job.status, - metadata: job.metadata, - } + if (job.output !== undefined) response.output = job.output + if (job.error !== undefined) response.error = job.error - if (job.output !== undefined) response.output = job.output - if (job.error !== undefined) response.error = job.error + return NextResponse.json(response) + } catch (error: unknown) { + const errorMessage = toError(error).message + logger.error(`[${requestId}] Error fetching task status:`, error) - return NextResponse.json(response) - } catch (error: unknown) { - const errorMessage = toError(error).message - logger.error(`[${requestId}] Error fetching task status:`, error) + if (errorMessage?.includes('not found')) { + return createErrorResponse('Task not found', 404) + } - if (errorMessage?.includes('not found')) { - 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 c5e7878fc69..210826cfe76 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 @@ -253,4 +254,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 6ffee2355a1..3bc7bb41b46 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 @@ -286,12 +287,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 @@ -422,4 +423,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 1ace24c886b..57ea35e6161 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 @@ -103,4 +104,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 41df290d871..e4e64724f24 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 { 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 { dispatchSync } from '@/lib/knowledge/connectors/sync-engine' import { allocateTagSlots } from '@/lib/knowledge/constants' import { createTagDefinition } from '@/lib/knowledge/tags/service' @@ -31,291 +32,301 @@ 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, + knowledgeBaseName: writeCheck.knowledgeBase.name, + connectorType, + syncIntervalMinutes, + authMode: connectorConfig.auth.mode, + }, + 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, - knowledgeBaseName: writeCheck.knowledgeBase.name, - connectorType, - syncIntervalMinutes, - authMode: connectorConfig.auth.mode, - }, - 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 630db09774f..82c51874de9 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 { generateId } from '@sim/utils/id' 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 { 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 f238ac4f978..bf70faecefd 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,273 +49,270 @@ 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 + } +) - const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId) +export const PUT = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> }) => { + const requestId = generateRequestId() + const { id: knowledgeBaseId, documentId } = await params - if (!accessCheck.hasAccess) { - if (accessCheck.notFound) { + 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) + + 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 + } else { + const updatedDocument = await updateDocument(documentId, validatedData, requestId) - 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, - } + 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 "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseId, + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: validatedData.filename ?? accessCheck.document?.filename, + updatedFields: Object.keys(validatedData).filter( + (k) => validatedData[k as keyof typeof validatedData] !== undefined + ), + ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), + }, + 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 "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename ?? accessCheck.document?.filename, - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), - }, - 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}` ) - 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 "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`, - metadata: { - knowledgeBaseId, - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: accessCheck.document?.filename, - fileSize: accessCheck.document?.fileSize, - mimeType: accessCheck.document?.mimeType, - }, - 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 }) + 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 "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseId, + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: accessCheck.document?.filename, + fileSize: accessCheck.document?.fileSize, + mimeType: accessCheck.document?.mimeType, + }, + 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 }) + } } -} +) 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 8f8cdaaf537..34a707021a3 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 { generateId } from '@sim/utils/id' 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 { 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 02e5793a691..5e67472d537 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 { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { bulkDocumentOperation, bulkDocumentOperationByFilter, @@ -65,412 +66,421 @@ 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: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileCount: createdDocuments.length, - }, - 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: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileCount: createdDocuments.length, }, - }, - }) - } 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: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - 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: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, + 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 38b735dd090..ebcae6ab053 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 { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentRecords, deleteDocument, @@ -34,216 +35,221 @@ 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) + 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) + + 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 (!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 (docsByFilename.length > 0) { + existingDocumentId = docsByFilename[0].id + } } - 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) - ) + if (existingDocumentId) { + isUpdate = true + logger.info( + `[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old` ) - .limit(1) + } + + const createdDocuments = await createDocumentRecords( + [ + { + filename: validatedData.filename, + fileUrl: validatedData.fileUrl, + fileSize: validatedData.fileSize, + mimeType: validatedData.mimeType, + ...(validatedData.documentTagsData && { + documentTagsData: validatedData.documentTagsData, + }), + }, + ], + knowledgeBaseId, + requestId + ) - if (existingDoc.length > 0) { - existingDocumentId = existingDoc[0].id + 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 }) } - } 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) { + 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 } + ) + } } - } - if (existingDocumentId) { - isUpdate = true - logger.info( - `[${requestId}] Found existing document ${existingDocumentId}, creating replacement before deleting old` - ) - } + processDocumentsWithQueue( + createdDocuments, + knowledgeBaseId, + validatedData.processingOptions ?? {}, + requestId + ).catch((error: unknown) => { + logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) + }) + + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.knowledgeBaseDocumentsUploaded({ + knowledgeBaseId, + documentsCount: 1, + uploadType: 'single', + recipe: validatedData.processingOptions?.recipe, + }) + } catch (_e) { + // Silently fail + } - const createdDocuments = await createDocumentRecords( - [ - { - filename: validatedData.filename, - fileUrl: validatedData.fileUrl, + 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: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: validatedData.filename, + fileType: validatedData.mimeType, fileSize: validatedData.fileSize, - mimeType: validatedData.mimeType, - ...(validatedData.documentTagsData && { - documentTagsData: validatedData.documentTagsData, - }), + previousDocumentId: existingDocumentId, + isUpdate, }, - ], - 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 }) - } + request: req, + }) - if (existingDocumentId) { - try { - await deleteDocument(existingDocumentId, requestId) - } catch (deleteError) { - logger.error( - `[${requestId}] Failed to delete old document ${existingDocumentId}, rolling back new record`, - deleteError + 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, + }, + }, + }) + } 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 } ) - await deleteDocument(firstDocument.documentId, requestId).catch(() => {}) - return NextResponse.json({ error: 'Failed to replace existing document' }, { status: 500 }) } - } - processDocumentsWithQueue( - createdDocuments, - knowledgeBaseId, - validatedData.processingOptions ?? {}, - requestId - ).catch((error: unknown) => { - logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) - }) + logger.error(`[${requestId}] Error upserting document`, error) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - recipe: validatedData.processingOptions?.recipe, - }) - } catch (_e) { - // Silently fail - } + 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' - 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: { - knowledgeBaseName: accessCheck.knowledgeBase?.name, - fileName: validatedData.filename, - fileType: validatedData.mimeType, - fileSize: validatedData.fileSize, - 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, - }, - }, - }) - } 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 } + { error: errorMessage }, + { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } ) } - - 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' - - 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 77ddad14d4d..1c845e0eeaa 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 { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +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 02d8b3e5afd..d8b0e89e7a2 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -6,75 +6,78 @@ 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}"`, - metadata: { - knowledgeBaseName: 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}"`, + metadata: { + knowledgeBaseName: 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 5da7026a454..18951456d7f 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,207 +52,210 @@ 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}`) + 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 }) + } + + 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}"`, + metadata: { + updatedFields: Object.keys(validatedData).filter( + (k) => validatedData[k as keyof typeof validatedData] !== undefined + ), + ...(validatedData.name && { newName: validatedData.name }), + ...(validatedData.description !== undefined && { + description: validatedData.description, + }), + ...(validatedData.chunkingConfig && { + chunkMaxSize: validatedData.chunkingConfig.maxSize, + chunkMinSize: validatedData.chunkingConfig.minSize, + chunkOverlap: validatedData.chunkingConfig.overlap, + }), + }, + 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 + } + } catch (error) { + if (error instanceof KnowledgeBaseConflictError) { + return NextResponse.json({ error: error.message }, { status: 409 }) } - logger.warn( - `[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}` - ) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + 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 updatedKnowledgeBase = await updateKnowledgeBase( - id, - { - name: validatedData.name, - description: validatedData.description, - workspaceId: validatedData.workspaceId, - chunkingConfig: validatedData.chunkingConfig, - }, - requestId - ) + const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId) - logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${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_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}"`, + resourceName: accessCheck.knowledgeBase.name, + description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, metadata: { - updatedFields: Object.keys(validatedData).filter( - (k) => validatedData[k as keyof typeof validatedData] !== undefined - ), - ...(validatedData.name && { newName: validatedData.name }), - ...(validatedData.description !== undefined && { - description: validatedData.description, - }), - ...(validatedData.chunkingConfig && { - chunkMaxSize: validatedData.chunkingConfig.maxSize, - chunkMinSize: validatedData.chunkingConfig.minSize, - chunkOverlap: validatedData.chunkingConfig.overlap, - }), + knowledgeBaseName: accessCheck.knowledgeBase.name, }, - request: req, + 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}"`, - metadata: { - knowledgeBaseName: accessCheck.knowledgeBase.name, - }, - 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 c78bc753513..a33bdce2fe5 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 { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +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 69ce37b7ad5..395cf3901d4 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 { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +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 03517068dc9..08d820ddbdb 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 { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +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 9641f3a4539..e11c7838d48 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, @@ -73,7 +74,7 @@ const CreateKnowledgeBaseSchema = z.object({ ), }) -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const requestId = generateRequestId() try { @@ -100,9 +101,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 { @@ -199,4 +200,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 ebcc13983ba..7891a763bc6 100644 --- a/apps/sim/app/api/logs/cleanup/route.ts +++ b/apps/sim/app/api/logs/cleanup/route.ts @@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const dynamic = 'force-dynamic' const logger = createLogger('LogsCleanupAPI') -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { try { const authError = verifyCronAuth(request, 'logs cleanup') if (authError) return authError @@ -21,4 +22,4 @@ export async function GET(request: NextRequest) { logger.error('Failed to dispatch log cleanup jobs:', { error }) return NextResponse.json({ error: 'Failed to dispatch 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 77235c3ff55..290ea6bd145 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -29,6 +29,7 @@ import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/de import { env } from '@/lib/core/config/env' import { RateLimiter } from '@/lib/core/rate-limiter' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeWorkflowByWorkspacePermission, resolveWorkflowIdForUser, @@ -526,14 +527,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) { @@ -566,9 +567,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: { @@ -579,12 +580,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 002cd96764e..60a93d98132 100644 --- a/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/refresh/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' 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' @@ -154,103 +155,107 @@ async function syncToolSchemasToWorkflows( } } -export const POST = withMcpAuth<{ id: string }>('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - const { id: serverId } = await params - - try { - logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) - - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) +export const POST = withRouteHandler( + withMcpAuth<{ id: string }>('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + const { id: serverId } = await params + + try { + logger.info(`[${requestId}] Refreshing MCP server: ${serverId}`) + + const [server] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } + .limit(1) - let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error' - let toolCount = 0 - let lastError: string | null = null - let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] } - let discoveredTools: McpTool[] = [] + if (!server) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) + } - const currentStatusConfig: McpServerStatusConfig = - (server.statusConfig as McpServerStatusConfig | null) ?? { - consecutiveFailures: 0, - lastSuccessfulDiscovery: null, + let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error' + let toolCount = 0 + let lastError: string | null = null + let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] } + let discoveredTools: McpTool[] = [] + + const currentStatusConfig: McpServerStatusConfig = + (server.statusConfig as McpServerStatusConfig | null) ?? { + consecutiveFailures: 0, + lastSuccessfulDiscovery: null, + } + + try { + discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + connectionStatus = 'connected' + toolCount = discoveredTools.length + logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`) + + syncResult = await syncToolSchemasToWorkflows( + workspaceId, + serverId, + discoveredTools, + requestId, + { url: server.url ?? undefined, name: server.name ?? undefined } + ) + } catch (error) { + connectionStatus = 'error' + lastError = + error instanceof Error + ? error.message.split('\n')[0].slice(0, 200) + : 'Connection failed' + logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) } - try { - discoveredTools = await mcpService.discoverServerTools(userId, serverId, workspaceId) - connectionStatus = 'connected' - toolCount = discoveredTools.length - logger.info(`[${requestId}] Discovered ${toolCount} tools from server ${serverId}`) - - syncResult = await syncToolSchemasToWorkflows( - workspaceId, - serverId, - discoveredTools, - requestId, - { url: server.url ?? undefined, name: server.name ?? undefined } - ) - } catch (error) { - connectionStatus = 'error' - lastError = - error instanceof Error ? error.message.split('\n')[0].slice(0, 200) : 'Connection failed' - logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) - } + const now = new Date() + const newStatusConfig = + connectionStatus === 'connected' + ? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() } + : { + consecutiveFailures: currentStatusConfig.consecutiveFailures + 1, + lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery, + } + + const [refreshedServer] = await db + .update(mcpServers) + .set({ + lastToolsRefresh: now, + connectionStatus, + lastError, + lastConnected: connectionStatus === 'connected' ? now : server.lastConnected, + toolCount, + statusConfig: newStatusConfig, + updatedAt: now, + }) + .where(eq(mcpServers.id, serverId)) + .returning() + + if (connectionStatus === 'connected') { + await mcpService.clearCache(workspaceId) + } - const now = new Date() - const newStatusConfig = - connectionStatus === 'connected' - ? { consecutiveFailures: 0, lastSuccessfulDiscovery: now.toISOString() } - : { - consecutiveFailures: currentStatusConfig.consecutiveFailures + 1, - lastSuccessfulDiscovery: currentStatusConfig.lastSuccessfulDiscovery, - } - - const [refreshedServer] = await db - .update(mcpServers) - .set({ - lastToolsRefresh: now, - connectionStatus, - lastError, - lastConnected: connectionStatus === 'connected' ? now : server.lastConnected, + return createMcpSuccessResponse({ + status: connectionStatus, toolCount, - statusConfig: newStatusConfig, - updatedAt: now, + lastConnected: refreshedServer?.lastConnected?.toISOString() || null, + error: lastError, + workflowsUpdated: syncResult.updatedCount, + updatedWorkflowIds: syncResult.updatedWorkflowIds, }) - .where(eq(mcpServers.id, serverId)) - .returning() - - if (connectionStatus === 'connected') { - await mcpService.clearCache(workspaceId) + } catch (error) { + logger.error(`[${requestId}] Error refreshing MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) } - - return createMcpSuccessResponse({ - status: connectionStatus, - toolCount, - lastConnected: refreshedServer?.lastConnected?.toISOString() || null, - error: lastError, - workflowsUpdated: syncResult.updatedCount, - updatedWorkflowIds: syncResult.updatedWorkflowIds, - }) - } catch (error) { - logger.error(`[${requestId}] Error refreshing MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to refresh MCP server', 500) } - } + ) ) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index b13a1897855..bcffb6b8232 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -5,6 +5,7 @@ import { toError } from '@sim/utils/errors' 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, @@ -23,123 +24,128 @@ 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}"`, + metadata: { + serverName: updatedServer.name, + transport: updatedServer.transport, + url: updatedServer.url, + updatedFields: Object.keys(updateData).filter( + (k) => k !== 'workspaceId' && k !== 'updatedAt' + ), + }, + 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}"`, - metadata: { - serverName: updatedServer.name, - transport: updatedServer.transport, - url: updatedServer.url, - updatedFields: Object.keys(updateData).filter( - (k) => k !== 'workspaceId' && k !== 'updatedAt' - ), - }, - request, - }) - - return createMcpSuccessResponse({ server: updatedServer }) - } catch (error) { - logger.error(`[${requestId}] Error updating MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to update MCP server', 500) + return createMcpSuccessResponse({ server: updatedServer }) + } catch (error) { + logger.error(`[${requestId}] Error updating MCP server:`, error) + return createMcpErrorResponse(toError(error), '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 13fb23a6702..4b9c8d93d47 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id' 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, @@ -29,8 +30,8 @@ export const dynamic = 'force-dynamic' /** * GET - List all registered MCP servers for the workspace */ -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const GET = withRouteHandler( + withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) @@ -47,7 +48,7 @@ export const GET = withMcpAuth('read')( logger.error(`[${requestId}] Error listing MCP servers:`, error) return createMcpErrorResponse(toError(error), 'Failed to list MCP servers', 500) } - } + }) ) /** @@ -60,62 +61,93 @@ 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')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const body = getParsedBody(request) || (await request.json()) - - logger.info(`[${requestId}] Registering MCP server:`, { - name: body.name, - transport: body.transport, - workspaceId, - }) - - if (!body.name || !body.transport) { - return createMcpErrorResponse( - new Error('Missing required fields: name or transport'), - 'Missing required fields', - 400 - ) - } - +export const POST = withRouteHandler( + withMcpAuth('write')( + async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { - validateMcpDomain(body.url) - } catch (e) { - if (e instanceof McpDomainNotAllowedError) { - return createMcpErrorResponse(e, e.message, 403) - } - throw e - } + const body = getParsedBody(request) || (await request.json()) - try { - await validateMcpServerSsrf(body.url) - } catch (e) { - if (e instanceof McpDnsResolutionError) { - return createMcpErrorResponse(e, e.message, 502) - } - if (e instanceof McpSsrfError) { - return createMcpErrorResponse(e, e.message, 403) + logger.info(`[${requestId}] Registering MCP server:`, { + name: body.name, + transport: body.transport, + workspaceId, + }) + + if (!body.name || !body.transport) { + return createMcpErrorResponse( + new Error('Missing required fields: name or transport'), + 'Missing required fields', + 400 + ) } - throw e - } - const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : generateId() + try { + validateMcpDomain(body.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } - const [existingServer] = await db - .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) + try { + await validateMcpServerSsrf(body.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 + } - if (existingServer) { - logger.info( - `[${requestId}] Server with ID ${serverId} already exists, updating instead of creating` - ) + const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : generateId() + + const [existingServer] = await db + .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + .limit(1) + + if (existingServer) { + logger.info( + `[${requestId}] Server with ID ${serverId} already exists, updating instead of creating` + ) + + await db + .update(mcpServers) + .set({ + name: body.name, + description: body.description, + transport: body.transport, + url: body.url, + headers: body.headers || {}, + timeout: body.timeout || 30000, + retries: body.retries || 3, + enabled: body.enabled !== false, + connectionStatus: 'connected', + lastConnected: new Date(), + updatedAt: new Date(), + deletedAt: null, + }) + .where(eq(mcpServers.id, serverId)) + + await mcpService.clearCache(workspaceId) + + logger.info( + `[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})` + ) + + return createMcpSuccessResponse({ serverId, updated: true }, 200) + } await db - .update(mcpServers) - .set({ + .insert(mcpServers) + .values({ + id: serverId, + workspaceId, + createdBy: userId, name: body.name, description: body.description, transport: body.transport, @@ -126,171 +158,146 @@ export const POST = withMcpAuth('write')( enabled: body.enabled !== false, connectionStatus: 'connected', lastConnected: new Date(), + createdAt: new Date(), updatedAt: new Date(), - deletedAt: null, }) - .where(eq(mcpServers.id, serverId)) + .returning() await mcpService.clearCache(workspaceId) logger.info( - `[${requestId}] Successfully updated MCP server: ${body.name} (ID: ${serverId})` + `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})` ) - return createMcpSuccessResponse({ serverId, updated: true }, 200) - } - - await db - .insert(mcpServers) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name, - description: body.description, - transport: body.transport, - url: body.url, - headers: body.headers || {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - await mcpService.clearCache(workspaceId) + try { + const { PlatformEvents } = await import('@/lib/core/telemetry') + PlatformEvents.mcpServerAdded({ + serverId, + serverName: body.name, + transport: body.transport, + workspaceId, + }) + } catch (_e) { + // Silently fail + } - logger.info( - `[${requestId}] Successfully registered MCP server: ${body.name} (ID: ${serverId})` - ) + const sourceParam = body.source as string | undefined + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + + captureServerEvent( + userId, + 'mcp_server_connected', + { workspace_id: workspaceId, server_name: body.name, transport: body.transport, source }, + { + groups: { workspace: workspaceId }, + setOnce: { first_mcp_connected_at: new Date().toISOString() }, + } + ) - try { - const { PlatformEvents } = await import('@/lib/core/telemetry') - PlatformEvents.mcpServerAdded({ - serverId, - serverName: body.name, - transport: body.transport, + recordAudit({ workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: body.name, + description: `Added MCP server "${body.name}"`, + metadata: { + serverName: body.name, + transport: body.transport, + url: body.url, + timeout: body.timeout || 30000, + retries: body.retries || 3, + source: source, + }, + request, }) - } catch (_e) { - // Silently fail - } - const sourceParam = body.source as string | undefined - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - captureServerEvent( - userId, - 'mcp_server_connected', - { workspace_id: workspaceId, server_name: body.name, transport: body.transport, source }, - { - groups: { workspace: workspaceId }, - setOnce: { first_mcp_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name, - description: `Added MCP server "${body.name}"`, - metadata: { - serverName: body.name, - transport: body.transport, - url: body.url, - timeout: body.timeout || 30000, - retries: body.retries || 3, - source: source, - }, - request, - }) - - return createMcpSuccessResponse({ serverId }, 201) - } catch (error) { - logger.error(`[${requestId}] Error registering MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) + return createMcpSuccessResponse({ serverId }, 201) + } catch (error) { + logger.error(`[${requestId}] Error registering MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to register MCP server', 500) + } } - } + ) ) /** * DELETE - Delete an MCP server from the workspace (requires admin permission) */ -export const DELETE = withMcpAuth('admin')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const { searchParams } = new URL(request.url) - const serverId = searchParams.get('serverId') - const sourceParam = searchParams.get('source') - const source = - sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined - - if (!serverId) { - return createMcpErrorResponse( - new Error('serverId parameter is required'), - 'Missing required parameter', - 400 - ) - } +export const DELETE = withRouteHandler( + withMcpAuth('admin')( + async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const { searchParams } = new URL(request.url) + const serverId = searchParams.get('serverId') + const sourceParam = searchParams.get('source') + const source = + sourceParam === 'settings' || sourceParam === 'tool_input' ? sourceParam : undefined + + if (!serverId) { + return createMcpErrorResponse( + new Error('serverId parameter is required'), + 'Missing required parameter', + 400 + ) + } - logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) + logger.info( + `[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}` + ) - const [deletedServer] = await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning() + const [deletedServer] = await db + .delete(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + .returning() + + if (!deletedServer) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) + } - if (!deletedServer) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } + await mcpService.clearCache(workspaceId) - await mcpService.clearCache(workspaceId) + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) + captureServerEvent( + userId, + 'mcp_server_disconnected', + { workspace_id: workspaceId, server_name: deletedServer.name, source }, + { groups: { workspace: workspaceId } } + ) - captureServerEvent( - userId, - 'mcp_server_disconnected', - { workspace_id: workspaceId, server_name: deletedServer.name, source }, - { groups: { workspace: workspaceId } } - ) + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId!, + resourceName: deletedServer.name, + description: `Removed MCP server "${deletedServer.name}"`, + metadata: { + serverName: deletedServer.name, + transport: deletedServer.transport, + url: deletedServer.url, + source, + }, + request, + }) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId!, - resourceName: deletedServer.name, - description: `Removed MCP server "${deletedServer.name}"`, - metadata: { - serverName: deletedServer.name, - transport: deletedServer.transport, - url: deletedServer.url, - source, - }, - request, - }) - - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) - } catch (error) { - logger.error(`[${requestId}] Error deleting MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to delete MCP server', 500) + } } - } + ) ) 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 648730f0f4f..f565a184f12 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { NextRequest } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { McpClient } from '@/lib/mcp/client' import { McpDnsResolutionError, @@ -65,8 +66,8 @@ function sanitizeConnectionError(error: unknown): string { /** * POST - Test connection to an MCP server before registering it */ -export const POST = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const POST = withRouteHandler( + withMcpAuth('write')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const body: TestConnectionRequest = getParsedBody(request) || (await request.json()) @@ -223,5 +224,5 @@ export const POST = withMcpAuth('write')( logger.error(`[${requestId}] Error testing MCP server connection:`, error) return createMcpErrorResponse(toError(error), 'Failed to test server connection', 500) } - } + }) ) diff --git a/apps/sim/app/api/mcp/tools/discover/route.ts b/apps/sim/app/api/mcp/tools/discover/route.ts index b62470274ae..b6a0f7c09a3 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,8 +10,8 @@ const logger = createLogger('McpToolDiscoveryAPI') export const dynamic = 'force-dynamic' -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const GET = withRouteHandler( + withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') @@ -42,11 +43,11 @@ export const GET = withMcpAuth('read')( const { message, status } = categorizeError(error) return createMcpErrorResponse(new Error(message), 'Failed to discover MCP tools', status) } - } + }) ) -export const POST = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const POST = withRouteHandler( + withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) const { serverIds } = body @@ -98,5 +99,5 @@ export const POST = withMcpAuth('read')( const { message, status } = categorizeError(error) return createMcpErrorResponse(new Error(message), 'Failed to refresh tool discovery', status) } - } + }) ) diff --git a/apps/sim/app/api/mcp/tools/execute/route.ts b/apps/sim/app/api/mcp/tools/execute/route.ts index 258bdbcafde..989ea5b544f 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,8 +41,8 @@ function hasType(prop: unknown): prop is SchemaProperty { /** * POST - Execute a tool on an MCP server */ -export const POST = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const POST = withRouteHandler( + withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) @@ -224,7 +225,7 @@ export const POST = withMcpAuth('read')( const { message, status } = categorizeError(error) return createMcpErrorResponse(new Error(message), message, status) } - } + }) ) function validateToolArguments(tool: McpTool, args: Record): string | null { diff --git a/apps/sim/app/api/mcp/tools/stored/route.ts b/apps/sim/app/api/mcp/tools/stored/route.ts index 12792c8d5ff..59fa5f5102f 100644 --- a/apps/sim/app/api/mcp/tools/stored/route.ts +++ b/apps/sim/app/api/mcp/tools/stored/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' 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' @@ -12,8 +13,8 @@ const logger = createLogger('McpStoredToolsAPI') export const dynamic = 'force-dynamic' -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const GET = withRouteHandler( + withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Fetching stored MCP tools for workspace ${workspaceId}`) @@ -73,5 +74,5 @@ export const GET = withMcpAuth('read')( logger.error(`[${requestId}] Error fetching stored MCP tools:`, error) return createMcpErrorResponse(toError(error), 'Failed to fetch stored MCP tools', 500) } - } + }) ) 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 49699160995..21c76cf46fd 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -5,6 +5,7 @@ import { toError } from '@sim/utils/errors' 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' @@ -20,181 +21,187 @@ interface RouteParams { /** * GET - Get a specific workflow MCP server with its tools */ -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId } = await params - - logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) - - const [server] = await db - .select({ - id: workflowMcpServer.id, - workspaceId: workflowMcpServer.workspaceId, - createdBy: workflowMcpServer.createdBy, - name: workflowMcpServer.name, - description: workflowMcpServer.description, - isPublic: workflowMcpServer.isPublic, - createdAt: workflowMcpServer.createdAt, - updatedAt: workflowMcpServer.updatedAt, - }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) +export const GET = withRouteHandler( + withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + + logger.info(`[${requestId}] Getting workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ + id: workflowMcpServer.id, + workspaceId: workflowMcpServer.workspaceId, + createdBy: workflowMcpServer.createdBy, + name: workflowMcpServer.name, + description: workflowMcpServer.description, + isPublic: workflowMcpServer.isPublic, + createdAt: workflowMcpServer.createdAt, + updatedAt: workflowMcpServer.updatedAt, + }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) - ) - .limit(1) + .limit(1) - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } - const tools = await db - .select() - .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + const tools = await db + .select() + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info( - `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` - ) + logger.info( + `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` + ) - return createMcpSuccessResponse({ server, tools }) - } catch (error) { - logger.error(`[${requestId}] Error getting workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) + return createMcpSuccessResponse({ server, tools }) + } catch (error) { + logger.error(`[${requestId}] Error getting workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to get workflow MCP server', 500) + } } - } + ) ) /** * PATCH - Update a workflow MCP server */ -export const PATCH = withMcpAuth('write')( - async ( - request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, - { params } - ) => { - try { - const { id: serverId } = await params - const body = getParsedBody(request) || (await request.json()) - - logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) - - const [existingServer] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) +export const PATCH = withRouteHandler( + withMcpAuth('write')( + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { + try { + const { id: serverId } = await params + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Updating workflow MCP server: ${serverId}`) + + const [existingServer] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) - ) - .limit(1) - - if (!existingServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const updateData: Record = { - updatedAt: new Date(), - } + .limit(1) + + if (!existingServer) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const updateData: Record = { + updatedAt: new Date(), + } + + if (body.name !== undefined) { + updateData.name = body.name.trim() + } + if (body.description !== undefined) { + updateData.description = body.description?.trim() || null + } + if (body.isPublic !== undefined) { + updateData.isPublic = body.isPublic + } + + const [updatedServer] = await db + .update(workflowMcpServer) + .set(updateData) + .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) + .returning() + + logger.info(`[${requestId}] Successfully updated workflow 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, + description: `Updated workflow MCP server "${updatedServer.name}"`, + metadata: { + serverName: updatedServer.name, + isPublic: updatedServer.isPublic, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, + request, + }) - if (body.name !== undefined) { - updateData.name = body.name.trim() - } - if (body.description !== undefined) { - updateData.description = body.description?.trim() || null + return createMcpSuccessResponse({ server: updatedServer }) + } catch (error) { + logger.error(`[${requestId}] Error updating workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to update workflow MCP server', 500) } - if (body.isPublic !== undefined) { - updateData.isPublic = body.isPublic - } - - const [updatedServer] = await db - .update(workflowMcpServer) - .set(updateData) - .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) - .returning() - - logger.info(`[${requestId}] Successfully updated workflow 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, - description: `Updated workflow MCP server "${updatedServer.name}"`, - metadata: { - serverName: updatedServer.name, - isPublic: updatedServer.isPublic, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - - return createMcpSuccessResponse({ server: updatedServer }) - } catch (error) { - logger.error(`[${requestId}] Error updating workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to update workflow MCP server', 500) } - } + ) ) /** * DELETE - Delete a workflow MCP server and all its tools */ -export const DELETE = withMcpAuth('admin')( - async ( - request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, - { params } - ) => { - try { - const { id: serverId } = await params - - logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) - - const [deletedServer] = await db - .delete(workflowMcpServer) - .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) - ) - .returning() +export const DELETE = withRouteHandler( + withMcpAuth('admin')( + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { + try { + const { id: serverId } = await params + + logger.info(`[${requestId}] Deleting workflow MCP server: ${serverId}`) + + const [deletedServer] = await db + .delete(workflowMcpServer) + .where( + and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + ) + .returning() + + if (!deletedServer) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) + + mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: deletedServer.name, + description: `Unpublished workflow MCP server "${deletedServer.name}"`, + metadata: { serverName: deletedServer.name }, + request, + }) - if (!deletedServer) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to delete workflow MCP server', 500) } - - logger.info(`[${requestId}] Successfully deleted workflow MCP server: ${serverId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: deletedServer.name, - description: `Unpublished workflow MCP server "${deletedServer.name}"`, - metadata: { serverName: deletedServer.name }, - request, - }) - - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) - } catch (error) { - logger.error(`[${requestId}] Error deleting workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to delete workflow MCP server', 500) } - } + ) ) 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 e45ff5dc0dd..76fa504a887 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 @@ -5,6 +5,7 @@ import { toError } from '@sim/utils/errors' 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' @@ -22,210 +23,216 @@ interface RouteParams { /** * GET - Get a specific tool */ -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId, toolId } = await params - - logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) +export const GET = withRouteHandler( + withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId, toolId } = await params + + logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [tool] = await db - .select() - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [tool] = await db + .select() + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.id, toolId), + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt) + ) ) - ) - .limit(1) + .limit(1) - if (!tool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } + if (!tool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) + } - return createMcpSuccessResponse({ tool }) - } catch (error) { - logger.error(`[${requestId}] Error getting tool:`, error) - return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) + return createMcpSuccessResponse({ tool }) + } catch (error) { + logger.error(`[${requestId}] Error getting tool:`, error) + return createMcpErrorResponse(toError(error), 'Failed to get tool', 500) + } } - } + ) ) /** * PATCH - Update a tool's configuration */ -export const PATCH = withMcpAuth('write')( - async ( - request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, - { params } - ) => { - try { - const { id: serverId, toolId } = await params - const body = getParsedBody(request) || (await request.json()) - - logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) +export const PATCH = withRouteHandler( + withMcpAuth('write')( + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { + try { + const { id: serverId, toolId } = await params + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.id, toolId), - eq(workflowMcpTool.serverId, serverId), - isNull(workflowMcpTool.archivedAt) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [existingTool] = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.id, toolId), + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt) + ) ) - ) - .limit(1) - - if (!existingTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - const updateData: Record = { - updatedAt: new Date(), + .limit(1) + + if (!existingTool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) + } + + const updateData: Record = { + updatedAt: new Date(), + } + + if (body.toolName !== undefined) { + updateData.toolName = sanitizeToolName(body.toolName) + } + if (body.toolDescription !== undefined) { + updateData.toolDescription = body.toolDescription?.trim() || null + } + if (body.parameterSchema !== undefined) { + updateData.parameterSchema = body.parameterSchema + } + + const [updatedTool] = await db + .update(workflowMcpTool) + .set(updateData) + .where(eq(workflowMcpTool.id, toolId)) + .returning() + + logger.info(`[${requestId}] Successfully updated tool ${toolId}`) + + mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Updated tool "${updatedTool.toolName}" in MCP server`, + metadata: { + toolId, + toolName: updatedTool.toolName, + workflowId: updatedTool.workflowId, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, + request, + }) + + return createMcpSuccessResponse({ tool: updatedTool }) + } catch (error) { + logger.error(`[${requestId}] Error updating tool:`, error) + return createMcpErrorResponse(toError(error), 'Failed to update tool', 500) } - - if (body.toolName !== undefined) { - updateData.toolName = sanitizeToolName(body.toolName) - } - if (body.toolDescription !== undefined) { - updateData.toolDescription = body.toolDescription?.trim() || null - } - if (body.parameterSchema !== undefined) { - updateData.parameterSchema = body.parameterSchema - } - - const [updatedTool] = await db - .update(workflowMcpTool) - .set(updateData) - .where(eq(workflowMcpTool.id, toolId)) - .returning() - - logger.info(`[${requestId}] Successfully updated tool ${toolId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Updated tool "${updatedTool.toolName}" in MCP server`, - metadata: { - toolId, - toolName: updatedTool.toolName, - workflowId: updatedTool.workflowId, - updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), - }, - request, - }) - - return createMcpSuccessResponse({ tool: updatedTool }) - } catch (error) { - logger.error(`[${requestId}] Error updating tool:`, error) - return createMcpErrorResponse(toError(error), 'Failed to update tool', 500) } - } + ) ) /** * DELETE - Remove a tool from an MCP server */ -export const DELETE = withMcpAuth('write')( - async ( - request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, - { params } - ) => { - try { - const { id: serverId, toolId } = await params - - logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) +export const DELETE = withRouteHandler( + withMcpAuth('write')( + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { + try { + const { id: serverId, toolId } = await params + + logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [deletedTool] = await db + .delete(workflowMcpTool) + .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) + .returning() + + if (!deletedTool) { + return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) + } + + logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) + + mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Removed tool "${deletedTool.toolName}" from MCP server`, + metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId }, + request, + }) + + return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) + } catch (error) { + logger.error(`[${requestId}] Error deleting tool:`, error) + return createMcpErrorResponse(toError(error), 'Failed to delete tool', 500) } - - const [deletedTool] = await db - .delete(workflowMcpTool) - .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) - .returning() - - if (!deletedTool) { - return createMcpErrorResponse(new Error('Tool not found'), 'Tool not found', 404) - } - - logger.info(`[${requestId}] Successfully deleted tool ${toolId}`) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Removed tool "${deletedTool.toolName}" from MCP server`, - metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId }, - request, - }) - - return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) - } catch (error) { - logger.error(`[${requestId}] Error deleting tool:`, error) - return createMcpErrorResponse(toError(error), 'Failed to delete tool', 500) } - } + ) ) 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 783e05655de..08b71262c68 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 @@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id' 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' @@ -24,217 +25,221 @@ interface RouteParams { /** * GET - List all tools for a workflow MCP server */ -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { - try { - const { id: serverId } = await params - - logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) +export const GET = withRouteHandler( + withMcpAuth('read')( + async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + try { + const { id: serverId } = await params + + logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const tools = await db - .select({ - id: workflowMcpTool.id, - serverId: workflowMcpTool.serverId, - workflowId: workflowMcpTool.workflowId, - toolName: workflowMcpTool.toolName, - toolDescription: workflowMcpTool.toolDescription, - parameterSchema: workflowMcpTool.parameterSchema, - createdAt: workflowMcpTool.createdAt, - updatedAt: workflowMcpTool.updatedAt, - workflowName: workflow.name, - workflowDescription: workflow.description, - isDeployed: workflow.isDeployed, - }) - .from(workflowMcpTool) - .leftJoin( - workflow, - and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt)) - ) - .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const tools = await db + .select({ + id: workflowMcpTool.id, + serverId: workflowMcpTool.serverId, + workflowId: workflowMcpTool.workflowId, + toolName: workflowMcpTool.toolName, + toolDescription: workflowMcpTool.toolDescription, + parameterSchema: workflowMcpTool.parameterSchema, + createdAt: workflowMcpTool.createdAt, + updatedAt: workflowMcpTool.updatedAt, + workflowName: workflow.name, + workflowDescription: workflow.description, + isDeployed: workflow.isDeployed, + }) + .from(workflowMcpTool) + .leftJoin( + workflow, + and(eq(workflowMcpTool.workflowId, workflow.id), isNull(workflow.archivedAt)) + ) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) - logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) + logger.info(`[${requestId}] Found ${tools.length} tools for server ${serverId}`) - return createMcpSuccessResponse({ tools }) - } catch (error) { - logger.error(`[${requestId}] Error listing tools:`, error) - return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) + return createMcpSuccessResponse({ tools }) + } catch (error) { + logger.error(`[${requestId}] Error listing tools:`, error) + return createMcpErrorResponse(toError(error), 'Failed to list tools', 500) + } } - } + ) ) /** * POST - Add a workflow as a tool to an MCP server */ -export const POST = withMcpAuth('write')( - async ( - request: NextRequest, - { userId, userName, userEmail, workspaceId, requestId }, - { params } - ) => { - try { - const { id: serverId } = await params - const body = getParsedBody(request) || (await request.json()) - - logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, { - workflowId: body.workflowId, - }) - - if (!body.workflowId) { - return createMcpErrorResponse( - new Error('Missing required field: workflowId'), - 'Missing required field', - 400 - ) - } - - const [server] = await db - .select({ id: workflowMcpServer.id }) - .from(workflowMcpServer) - .where( - and( - eq(workflowMcpServer.id, serverId), - eq(workflowMcpServer.workspaceId, workspaceId), - isNull(workflowMcpServer.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) - } - - const [workflowRecord] = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, +export const POST = withRouteHandler( + withMcpAuth('write')( + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { + try { + const { id: serverId } = await params + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Adding tool to workflow MCP server: ${serverId}`, { + workflowId: body.workflowId, }) - .from(workflow) - .where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt))) - .limit(1) - if (!workflowRecord) { - return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) - } - - if (workflowRecord.workspaceId !== workspaceId) { - return createMcpErrorResponse( - new Error('Workflow does not belong to this workspace'), - 'Access denied', - 403 - ) - } - - if (!workflowRecord.isDeployed) { - return createMcpErrorResponse( - new Error('Workflow must be deployed before adding as a tool'), - 'Workflow not deployed', - 400 - ) - } - - const hasStartBlock = await hasValidStartBlock(body.workflowId) - if (!hasStartBlock) { - return createMcpErrorResponse( - new Error('Workflow must have a Start block to be used as an MCP tool'), - 'No start block found', - 400 - ) - } + if (!body.workflowId) { + return createMcpErrorResponse( + new Error('Missing required field: workflowId'), + 'Missing required field', + 400 + ) + } + + const [server] = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where( + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) + } + + const [workflowRecord] = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt))) + .limit(1) + + if (!workflowRecord) { + return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) + } + + if (workflowRecord.workspaceId !== workspaceId) { + return createMcpErrorResponse( + new Error('Workflow does not belong to this workspace'), + 'Access denied', + 403 + ) + } - const [existingTool] = await db - .select({ id: workflowMcpTool.id }) - .from(workflowMcpTool) - .where( - and( - eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, body.workflowId), - isNull(workflowMcpTool.archivedAt) + if (!workflowRecord.isDeployed) { + return createMcpErrorResponse( + new Error('Workflow must be deployed before adding as a tool'), + 'Workflow not deployed', + 400 ) - ) - .limit(1) + } + + const hasStartBlock = await hasValidStartBlock(body.workflowId) + if (!hasStartBlock) { + return createMcpErrorResponse( + new Error('Workflow must have a Start block to be used as an MCP tool'), + 'No start block found', + 400 + ) + } + + const [existingTool] = await db + .select({ id: workflowMcpTool.id }) + .from(workflowMcpTool) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + eq(workflowMcpTool.workflowId, body.workflowId), + isNull(workflowMcpTool.archivedAt) + ) + ) + .limit(1) - if (existingTool) { - return createMcpErrorResponse( - new Error('This workflow is already added as a tool to this server'), - 'Tool already exists', - 409 + if (existingTool) { + return createMcpErrorResponse( + new Error('This workflow is already added as a tool to this server'), + 'Tool already exists', + 409 + ) + } + + const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name) + const toolDescription = + body.toolDescription?.trim() || + workflowRecord.description || + `Execute ${workflowRecord.name} workflow` + + const parameterSchema = + body.parameterSchema && Object.keys(body.parameterSchema).length > 0 + ? body.parameterSchema + : await generateParameterSchemaForWorkflow(body.workflowId) + + const toolId = generateId() + const [tool] = await db + .insert(workflowMcpTool) + .values({ + id: toolId, + serverId, + workflowId: body.workflowId, + toolName, + toolDescription, + parameterSchema, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + logger.info( + `[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}` ) - } - const toolName = sanitizeToolName(body.toolName?.trim() || workflowRecord.name) - const toolDescription = - body.toolDescription?.trim() || - workflowRecord.description || - `Execute ${workflowRecord.name} workflow` - - const parameterSchema = - body.parameterSchema && Object.keys(body.parameterSchema).length > 0 - ? body.parameterSchema - : await generateParameterSchemaForWorkflow(body.workflowId) - - const toolId = generateId() - const [tool] = await db - .insert(workflowMcpTool) - .values({ - id: toolId, - serverId, - workflowId: body.workflowId, - toolName, - toolDescription, - parameterSchema, - createdAt: new Date(), - updatedAt: new Date(), + mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Added tool "${toolName}" to MCP server`, + metadata: { + toolId, + toolName, + toolDescription, + workflowId: body.workflowId, + workflowName: workflowRecord.name, + }, + request, }) - .returning() - - logger.info( - `[${requestId}] Successfully added tool ${toolName} (workflow: ${body.workflowId}) to server ${serverId}` - ) - - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - description: `Added tool "${toolName}" to MCP server`, - metadata: { - toolId, - toolName, - toolDescription, - workflowId: body.workflowId, - workflowName: workflowRecord.name, - }, - request, - }) - - return createMcpSuccessResponse({ tool }, 201) - } catch (error) { - logger.error(`[${requestId}] Error adding tool:`, error) - return createMcpErrorResponse(toError(error), 'Failed to add tool', 500) + + return createMcpSuccessResponse({ tool }, 201) + } catch (error) { + logger.error(`[${requestId}] Error adding tool:`, error) + return createMcpErrorResponse(toError(error), 'Failed to add tool', 500) + } } - } + ) ) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 089d2f9b713..43105b9297c 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, sql } 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' @@ -20,8 +21,8 @@ export const dynamic = 'force-dynamic' /** * GET - List all workflow MCP servers for the workspace */ -export const GET = withMcpAuth('read')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { +export const GET = withRouteHandler( + withMcpAuth('read')(async (request: NextRequest, { userId, workspaceId, requestId }) => { try { logger.info(`[${requestId}] Listing workflow MCP servers for workspace ${workspaceId}`) @@ -85,140 +86,142 @@ export const GET = withMcpAuth('read')( logger.error(`[${requestId}] Error listing workflow MCP servers:`, error) return createMcpErrorResponse(toError(error), 'Failed to list workflow MCP servers', 500) } - } + }) ) /** * POST - Create a new workflow MCP server */ -export const POST = withMcpAuth('write')( - async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { - try { - const body = getParsedBody(request) || (await request.json()) - - logger.info(`[${requestId}] Creating workflow MCP server:`, { - name: body.name, - workspaceId, - workflowIds: body.workflowIds, - }) - - if (!body.name) { - return createMcpErrorResponse( - new Error('Missing required field: name'), - 'Missing required field', - 400 - ) - } - - const serverId = generateId() - - const [server] = await db - .insert(workflowMcpServer) - .values({ - id: serverId, +export const POST = withRouteHandler( + withMcpAuth('write')( + async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { + try { + const body = getParsedBody(request) || (await request.json()) + + logger.info(`[${requestId}] Creating workflow MCP server:`, { + name: body.name, workspaceId, - createdBy: userId, - name: body.name.trim(), - description: body.description?.trim() || null, - isPublic: body.isPublic ?? false, - createdAt: new Date(), - updatedAt: new Date(), + workflowIds: body.workflowIds, }) - .returning() - - const workflowIds: string[] = body.workflowIds || [] - const addedTools: Array<{ workflowId: string; toolName: string }> = [] - - if (workflowIds.length > 0) { - const workflows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - isDeployed: workflow.isDeployed, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) - - for (const workflowRecord of workflows) { - if (workflowRecord.workspaceId !== workspaceId) { - logger.warn( - `[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace` - ) - continue - } - if (!workflowRecord.isDeployed) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`) - continue - } - - const hasStartBlock = await hasValidStartBlock(workflowRecord.id) - if (!hasStartBlock) { - logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`) - continue - } - - const toolName = sanitizeToolName(workflowRecord.name) - const toolDescription = - workflowRecord.description || `Execute ${workflowRecord.name} workflow` - - const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) + if (!body.name) { + return createMcpErrorResponse( + new Error('Missing required field: name'), + 'Missing required field', + 400 + ) + } - const toolId = generateId() - await db.insert(workflowMcpTool).values({ - id: toolId, - serverId, - workflowId: workflowRecord.id, - toolName, - toolDescription, - parameterSchema, + const serverId = generateId() + + const [server] = await db + .insert(workflowMcpServer) + .values({ + id: serverId, + workspaceId, + createdBy: userId, + name: body.name.trim(), + description: body.description?.trim() || null, + isPublic: body.isPublic ?? false, createdAt: new Date(), updatedAt: new Date(), }) + .returning() + + const workflowIds: string[] = body.workflowIds || [] + const addedTools: Array<{ workflowId: string; toolName: string }> = [] + + if (workflowIds.length > 0) { + const workflows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) + + for (const workflowRecord of workflows) { + if (workflowRecord.workspaceId !== workspaceId) { + logger.warn( + `[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace` + ) + continue + } + + if (!workflowRecord.isDeployed) { + logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`) + continue + } + + const hasStartBlock = await hasValidStartBlock(workflowRecord.id) + if (!hasStartBlock) { + logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`) + continue + } + + const toolName = sanitizeToolName(workflowRecord.name) + const toolDescription = + workflowRecord.description || `Execute ${workflowRecord.name} workflow` + + const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) + + const toolId = generateId() + await db.insert(workflowMcpTool).values({ + id: toolId, + serverId, + workflowId: workflowRecord.id, + toolName, + toolDescription, + parameterSchema, + createdAt: new Date(), + updatedAt: new Date(), + }) + + addedTools.push({ workflowId: workflowRecord.id, toolName }) + } - addedTools.push({ workflowId: workflowRecord.id, toolName }) + logger.info( + `[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`, + addedTools.map((t) => t.toolName) + ) + + if (addedTools.length > 0) { + mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + } } logger.info( - `[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`, - addedTools.map((t) => t.toolName) + `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` ) - if (addedTools.length > 0) { - mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) - } - } - - logger.info( - `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` - ) + recordAudit({ + workspaceId, + actorId: userId, + actorName: userName, + actorEmail: userEmail, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: body.name.trim(), + description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, + metadata: { + serverName: body.name.trim(), + isPublic: body.isPublic ?? false, + toolCount: addedTools.length, + toolNames: addedTools.map((t) => t.toolName), + workflowIds: addedTools.map((t) => t.workflowId), + }, + request, + }) - recordAudit({ - workspaceId, - actorId: userId, - actorName: userName, - actorEmail: userEmail, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: body.name.trim(), - description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, - metadata: { - serverName: body.name.trim(), - isPublic: body.isPublic ?? false, - toolCount: addedTools.length, - toolNames: addedTools.map((t) => t.toolName), - workflowIds: addedTools.map((t) => t.workflowId), - }, - request, - }) - - return createMcpSuccessResponse({ server, addedTools }, 201) - } catch (error) { - logger.error(`[${requestId}] Error creating workflow MCP server:`, error) - return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) + return createMcpSuccessResponse({ server, addedTools }, 201) + } catch (error) { + logger.error(`[${requestId}] Error creating workflow MCP server:`, error) + return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500) + } } - } + ) ) 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 64bceae92b2..8e05d527e8c 100644 --- a/apps/sim/app/api/memory/route.ts +++ b/apps/sim/app/api/memory/route.ts @@ -6,6 +6,7 @@ import { and, eq, isNull, like } 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' 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/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 91704f28fed..3b3324f733c 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -20,6 +20,7 @@ import { readEvents } from '@/lib/copilot/request/session/buffer' import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session' import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types' import { taskPubSub } from '@/lib/copilot/tasks' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('MothershipChatAPI') @@ -33,244 +34,245 @@ 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: StreamBatchEvent[] - previewSessions: FilePreviewSession[] - status: string - } | null = null + let streamSnapshot: { + events: StreamBatchEvent[] + previewSessions: FilePreviewSession[] + status: string + } | null = null - if (chat.conversationId) { - try { - const [events, previewSessions] = await Promise.all([ - readEvents(chat.conversationId, '0'), - readFilePreviewSessions(chat.conversationId).catch((error) => { - logger.warn('Failed to read preview sessions for mothership chat', { + if (chat.conversationId) { + try { + const [events, previewSessions] = await Promise.all([ + readEvents(chat.conversationId, '0'), + readFilePreviewSessions(chat.conversationId).catch((error) => { + logger.warn('Failed to read preview sessions for mothership chat', { + chatId, + conversationId: chat.conversationId, + error: toError(error).message, + }) + return [] + }), + ]) + const run = await getLatestRunForStream(chat.conversationId, userId).catch((error) => { + logger.warn('Failed to fetch latest run for mothership chat snapshot', { chatId, conversationId: chat.conversationId, error: toError(error).message, }) - return [] - }), - ]) - const run = await getLatestRunForStream(chat.conversationId, userId).catch((error) => { - logger.warn('Failed to fetch latest run for mothership chat snapshot', { + return null + }) + + streamSnapshot = { + events: events.map(toStreamBatchEvent), + previewSessions, + status: + typeof run?.status === 'string' + ? run.status + : events.length > 0 + ? 'active' + : 'unknown', + } + } catch (error) { + logger.warn('Failed to read stream snapshot for mothership chat', { chatId, conversationId: chat.conversationId, error: toError(error).message, }) - return null - }) - - streamSnapshot = { - events: events.map(toStreamBatchEvent), - previewSessions, - status: - typeof run?.status === 'string' ? run.status : events.length > 0 ? 'active' : 'unknown', } - } catch (error) { - logger.warn('Failed to read stream snapshot for mothership chat', { - chatId, - conversationId: chat.conversationId, - error: toError(error).message, - }) } - } - - const normalizedMessages = Array.isArray(chat.messages) - ? chat.messages - .filter((message): message is Record => Boolean(message)) - .map(normalizeMessage) - : [] - const effectiveMessages = buildEffectiveChatTranscript({ - messages: normalizedMessages, - activeStreamId: chat.conversationId || null, - ...(streamSnapshot ? { streamSnapshot } : {}), - }) - return NextResponse.json({ - success: true, - chat: { - id: chat.id, - title: chat.title, - messages: effectiveMessages, - conversationId: chat.conversationId || null, - resources: Array.isArray(chat.resources) ? chat.resources : [], - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, + const normalizedMessages = Array.isArray(chat.messages) + ? chat.messages + .filter((message): message is Record => Boolean(message)) + .map(normalizeMessage) + : [] + const effectiveMessages = buildEffectiveChatTranscript({ + messages: normalizedMessages, + activeStreamId: chat.conversationId || null, ...(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: effectiveMessages, + 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') } + } +) - const { chatId } = await params - if (!chatId) { - return createBadRequestResponse('chatId is required') - } +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 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') } + } +) - if (updatedChat.workspaceId) { - if (title !== undefined) { +export const DELETE = 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 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/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts index 344687ddfdc..af06ceaff8c 100644 --- a/apps/sim/app/api/mothership/chats/read/route.ts +++ b/apps/sim/app/api/mothership/chats/read/route.ts @@ -10,6 +10,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('MarkTaskReadAPI') @@ -17,7 +18,7 @@ const MarkReadSchema = z.object({ chatId: z.string().min(1), }) -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { @@ -40,4 +41,4 @@ export async function POST(request: NextRequest) { logger.error('Error marking task as read:', error) return createInternalServerErrorResponse('Failed to mark task as read') } -} +}) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 99bd6fd7390..d704451643d 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/http' import { taskPubSub } from '@/lib/copilot/tasks' +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 4f1646f6e34..20aee9ebd5c 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/tasks' +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 25c4618dbf6..23f0111c962 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -8,6 +8,7 @@ import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -45,7 +46,7 @@ function isAbortError(error: unknown): boolean { * 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 let requestId: string | undefined @@ -222,4 +223,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 200b541ff69..ecfd4b419b4 100644 --- a/apps/sim/app/api/notifications/poll/route.ts +++ b/apps/sim/app/api/notifications/poll/route.ts @@ -3,6 +3,7 @@ import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +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/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 6c2dbe054b2..1c6912bcea5 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -9,6 +9,7 @@ import { validateBulkInvitations, validateSeatAvailability, } from '@/lib/billing/validation/seat-management' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelPendingInvitation, createPendingInvitation, @@ -29,393 +30,399 @@ interface WorkspaceGrantPayload { permission: 'admin' | 'write' | 'read' } -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: 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) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - const userRole = memberEntry.role - if (!['owner', 'admin'].includes(userRole)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + const userRole = memberEntry.role + if (!['owner', 'admin'].includes(userRole)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - const invitations = await db - .select({ - id: invitation.id, - email: invitation.email, - kind: invitation.kind, - 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', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} + const invitations = await db + .select({ + id: invitation.id, + email: invitation.email, + kind: invitation.kind, + 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) -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({ + success: true, + data: { invitations, userRole }, + }) + } catch (error) { + logger.error('Failed to get organization invitations', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } + } +) + +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 }) + } - 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 invitationEmails = email ? [email] : emails + const body = await request.json() + const { email, emails, role = 'member', workspaceInvitations } = body + 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) + const [memberEntry] = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (!memberEntry) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - if (!['owner', 'admin'].includes(memberEntry.role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + if (!['owner', 'admin'].includes(memberEntry.role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } - if (validateOnly) { - const validationResult = await validateBulkInvitations(organizationId, invitationEmails) - return NextResponse.json({ - success: true, - data: validationResult, - validatedBy: session.user.id, - validatedAt: new Date().toISOString(), - }) - } + if (validateOnly) { + const validationResult = await validateBulkInvitations(organizationId, invitationEmails) + return NextResponse.json({ + success: true, + data: validationResult, + validatedBy: session.user.id, + validatedAt: new Date().toISOString(), + }) + } - 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) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } + if (!organizationEntry) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - const processedEmails = Array.from( - new Set( - invitationEmails - .map((raw: string) => { - const normalized = raw.trim().toLowerCase() - return quickValidateEmail(normalized).isValid ? normalized : null - }) - .filter((email): email is string => !!email) + const processedEmails = Array.from( + new Set( + invitationEmails + .map((raw: string) => { + const normalized = raw.trim().toLowerCase() + return quickValidateEmail(normalized).isValid ? normalized : null + }) + .filter((email): email is string => !!email) + ) ) - ) - - if (processedEmails.length === 0) { - return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 }) - } - const validGrants: WorkspaceGrantPayload[] = [] - if (isBatch) { - if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) { - return NextResponse.json( - { error: 'Select at least one organization workspace for this invitation.' }, - { status: 400 } - ) + if (processedEmails.length === 0) { + return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 }) } - 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 } - ) - } - - const [workspaceEntry] = await db - .select({ - id: workspace.id, - organizationId: workspace.organizationId, - workspaceMode: workspace.workspaceMode, - }) - .from(workspace) - .where(eq(workspace.id, wsInvitation.workspaceId)) - .limit(1) - - if (!workspaceEntry || !isOrganizationWorkspace(workspaceEntry)) { + const validGrants: WorkspaceGrantPayload[] = [] + if (isBatch) { + if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) { return NextResponse.json( - { - error: `Workspace ${wsInvitation.workspaceId} is not an organization-owned workspace.`, - }, + { error: 'Select at least one organization workspace for this invitation.' }, { status: 400 } ) } - if (workspaceEntry.organizationId !== organizationId) { - return NextResponse.json( - { - error: `Workspace ${wsInvitation.workspaceId} does not belong to this organization.`, - }, - { status: 400 } - ) + 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 } + ) + } + + const [workspaceEntry] = await db + .select({ + id: workspace.id, + organizationId: workspace.organizationId, + workspaceMode: workspace.workspaceMode, + }) + .from(workspace) + .where(eq(workspace.id, wsInvitation.workspaceId)) + .limit(1) + + if (!workspaceEntry || !isOrganizationWorkspace(workspaceEntry)) { + return NextResponse.json( + { + error: `Workspace ${wsInvitation.workspaceId} is not an organization-owned workspace.`, + }, + { status: 400 } + ) + } + + if (workspaceEntry.organizationId !== organizationId) { + return NextResponse.json( + { + error: `Workspace ${wsInvitation.workspaceId} does not belong to this organization.`, + }, + { status: 400 } + ) + } + + validGrants.push({ + workspaceId: wsInvitation.workspaceId, + permission: wsInvitation.permission, + }) } - - validGrants.push({ - workspaceId: wsInvitation.workspaceId, - permission: wsInvitation.permission, - }) } - } - 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.toLowerCase()) - const newEmails = processedEmails.filter((email) => !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.toLowerCase()) - const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email)) - - if (emailsToInvite.length === 0) { - const isSingleEmail = processedEmails.length === 1 - const existingMembersEmails = processedEmails.filter((email) => - existingEmails.includes(email) - ) - const pendingInvitationEmails = processedEmails.filter((email) => - pendingEmails.includes(email) - ) + 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.toLowerCase()) + const newEmails = processedEmails.filter((email) => !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.toLowerCase()) + const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email)) + + if (emailsToInvite.length === 0) { + const isSingleEmail = processedEmails.length === 1 + const existingMembersEmails = processedEmails.filter((email) => + existingEmails.includes(email) + ) + const pendingInvitationEmails = processedEmails.filter((email) => + 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 (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, + const seatValidation = await validateSeatAvailability(organizationId, emailsToInvite.length) + if (!seatValidation.canInvite) { + return NextResponse.json( + { + error: seatValidation.reason, + seatInfo: { + currentSeats: seatValidation.currentSeats, + maxSeats: seatValidation.maxSeats, + availableSeats: seatValidation.availableSeats, + seatsRequested: emailsToInvite.length, + }, }, - }, - { status: 400 } - ) - } + { status: 400 } + ) + } - const seatValidation = await validateSeatAvailability(organizationId, emailsToInvite.length) - if (!seatValidation.canInvite) { - return NextResponse.json( - { - error: seatValidation.reason, - seatInfo: { - currentSeats: seatValidation.currentSeats, - maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats, - seatsRequested: emailsToInvite.length, - }, - }, - { status: 400 } - ) - } + const [inviterRow] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - const [inviterRow] = await db - .select({ name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - - const sentInvitations: Array<{ id: string; email: string }> = [] - const failedInvitations: Array<{ email: string; error: string }> = [] - - for (const email of emailsToInvite) { - try { - const { invitationId, token } = await createPendingInvitation({ - kind: 'organization', - email, - inviterId: session.user.id, - organizationId, - role: role as 'admin' | 'member', - grants: validGrants, - }) + const sentInvitations: Array<{ id: string; email: string }> = [] + const failedInvitations: Array<{ email: string; error: string }> = [] - const emailResult = await sendInvitationEmail({ - invitationId, - token, - kind: 'organization', - email, - inviterName, - organizationId, - organizationRole: role as 'admin' | 'member', - grants: validGrants, - }) + for (const email of emailsToInvite) { + try { + const { invitationId, token } = await createPendingInvitation({ + kind: 'organization', + email, + inviterId: session.user.id, + organizationId, + role: role as 'admin' | 'member', + grants: validGrants, + }) - if (!emailResult.success) { - logger.error('Failed to send organization invitation email', { + const emailResult = await sendInvitationEmail({ + invitationId, + token, + kind: 'organization', email, - error: emailResult.error, + inviterName, + organizationId, + organizationRole: role as 'admin' | 'member', + grants: validGrants, }) + + if (!emailResult.success) { + logger.error('Failed to send organization invitation email', { + email, + error: emailResult.error, + }) + failedInvitations.push({ + email, + error: emailResult.error || 'Unknown email delivery error', + }) + await cancelPendingInvitation(invitationId) + continue + } + + sentInvitations.push({ id: invitationId, email }) + } catch (creationError) { + logger.error('Failed to create organization invitation', { email, error: creationError }) failedInvitations.push({ email, - error: emailResult.error || 'Unknown email delivery error', + error: + creationError instanceof Error + ? creationError.message + : 'Failed to create invitation', }) - await cancelPendingInvitation(invitationId) - continue } + } - sentInvitations.push({ id: invitationId, email }) - } catch (creationError) { - logger.error('Failed to create organization invitation', { email, error: creationError }) - failedInvitations.push({ - email, - error: - creationError instanceof Error ? creationError.message : 'Failed to create invitation', + for (const inv of sentInvitations) { + 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.name, + description: `Invited ${inv.email} to organization as ${role}`, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: role, + isBatch, + workspaceGrantCount: validGrants.length, + }, + request, }) } - } - for (const inv of sentInvitations) { - 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.name, - description: `Invited ${inv.email} to organization as ${role}`, - metadata: { - invitationId: inv.id, - targetEmail: inv.email, - targetRole: role, - isBatch, - workspaceGrantCount: validGrants.length, + const sentEmails = sentInvitations.map((inv) => inv.email) + const responseData = { + invitationsSent: sentInvitations.length, + invitedEmails: sentEmails, + failedInvitations, + existingMembers: processedEmails.filter((email) => existingEmails.includes(email)), + pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)), + invalidEmails: invitationEmails.filter( + (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid + ), + workspaceGrantsPerInvite: validGrants.length, + seatInfo: { + seatsUsed: seatValidation.currentSeats + sentInvitations.length, + maxSeats: seatValidation.maxSeats, + availableSeats: seatValidation.availableSeats - sentInvitations.length, }, - request, - }) - } - - const sentEmails = sentInvitations.map((inv) => inv.email) - const responseData = { - invitationsSent: sentInvitations.length, - invitedEmails: sentEmails, - failedInvitations, - existingMembers: processedEmails.filter((email) => existingEmails.includes(email)), - pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)), - invalidEmails: invitationEmails.filter( - (email: string) => !quickValidateEmail(email.trim().toLowerCase()).isValid - ), - workspaceGrantsPerInvite: validGrants.length, - seatInfo: { - seatsUsed: seatValidation.currentSeats + sentInvitations.length, - maxSeats: seatValidation.maxSeats, - availableSeats: seatValidation.availableSeats - sentInvitations.length, - }, - } + } - if (failedInvitations.length > 0 && sentInvitations.length === 0) { - return NextResponse.json( - { - success: false, - error: 'Failed to send invitation emails.', - message: 'No invitation emails could be delivered.', - data: responseData, - }, - { status: 502 } - ) - } + if (failedInvitations.length > 0 && sentInvitations.length === 0) { + return NextResponse.json( + { + success: false, + error: 'Failed to send invitation emails.', + message: 'No invitation emails could be delivered.', + data: responseData, + }, + { status: 502 } + ) + } - if (failedInvitations.length > 0) { - return NextResponse.json( - { - success: false, - error: 'Some invitation emails failed to send.', - message: `${sentInvitations.length} invitation(s) sent, ${failedInvitations.length} failed`, - data: responseData, - }, - { status: 207 } - ) - } + if (failedInvitations.length > 0) { + return NextResponse.json( + { + success: false, + error: 'Some invitation emails failed to send.', + message: `${sentInvitations.length} invitation(s) sent, ${failedInvitations.length} failed`, + data: responseData, + }, + { status: 207 } + ) + } - return NextResponse.json({ - success: true, - message: `${sentInvitations.length} invitation(s) sent successfully`, - data: responseData, - }) - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) + return NextResponse.json({ + success: true, + message: `${sentInvitations.length} invitation(s) sent successfully`, + data: responseData, + }) + } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + logger.error('Failed to create organization invitations', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - logger.error('Failed to create organization invitations', { 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 f7989367e50..16b2ecf5535 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' 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') @@ -22,368 +23,374 @@ 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) + const memberEntry = await memberQuery - 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 + 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 === 'owner') { - return NextResponse.json( - { - error: - 'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.', - }, - { status: 400 } - ) - } + if (role === 'owner') { + return NextResponse.json( + { + error: + 'Ownership transfer is not supported via this endpoint. Use POST /organizations/[id]/transfer-ownership instead.', + }, + { status: 400 } + ) + } - 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 }) - } - if (session.user.id === targetUserId) { - try { - await setActiveOrganizationForCurrentSession(null) - } catch (clearError) { - logger.warn('Failed to clear active organization after self-removal', { - userId: session.user.id, - organizationId, - error: clearError, - }) + if (session.user.id === targetUserId) { + try { + await setActiveOrganizationForCurrentSession(null) + } catch (clearError) { + logger.warn('Failed to clear active organization after self-removal', { + userId: session.user.id, + organizationId, + error: clearError, + }) + } } - } - 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 b56456588b9..0b6459a92fc 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -13,6 +13,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelPendingInvitation, createPendingInvitation, @@ -30,53 +31,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, @@ -85,257 +71,276 @@ 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)) - // The billing period is the same for every member — it comes from - // whichever subscription covers them. Fetch once and attach to - // every row instead of calling `getUserUsageData` per-member, - // which would run an O(N) pooled query for each of N rows. - const [orgSub] = await db - .select({ - periodStart: subscriptionTable.periodStart, - periodEnd: subscriptionTable.periodEnd, - }) - .from(subscriptionTable) - .where( - and( - eq(subscriptionTable.referenceId, organizationId), - inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + // 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)) + + // The billing period is the same for every member — it comes from + // whichever subscription covers them. Fetch once and attach to + // every row instead of calling `getUserUsageData` per-member, + // which would run an O(N) pooled query for each of N rows. + const [orgSub] = await db + .select({ + periodStart: subscriptionTable.periodStart, + periodEnd: subscriptionTable.periodEnd, + }) + .from(subscriptionTable) + .where( + and( + eq(subscriptionTable.referenceId, organizationId), + inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) ) - ) - .limit(1) - - const billingPeriodStart = orgSub?.periodStart ?? null - const billingPeriodEnd = orgSub?.periodEnd ?? null + .limit(1) + + const billingPeriodStart = orgSub?.periodStart ?? null + const billingPeriodEnd = orgSub?.periodEnd ?? null + + const membersWithUsage = base.map((row) => ({ + ...row, + billingPeriodStart, + billingPeriodEnd, + })) + + return NextResponse.json({ + success: true, + data: membersWithUsage, + total: membersWithUsage.length, + userRole, + hasAdminAccess, + }) + } - const membersWithUsage = base.map((row) => ({ - ...row, - billingPeriodStart, - billingPeriodEnd, - })) + 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 }) + } - await validateInvitationsAllowed(session.user.id) + await validateInvitationsAllowed(session.user.id) - const { id: organizationId } = await params - const { email, role = 'member' } = await request.json() + const { id: organizationId } = await params + const { email, role = 'member' } = await request.json() - if (!email) { - return NextResponse.json({ error: 'Email is required' }, { status: 400 }) - } + 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 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 } + ) + } - // 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 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 } + ) + } + } - if (existingUser.length > 0) { - const existingMember = await db + // 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 } - ) - } - - const { invitationId, token } = await createPendingInvitation({ - kind: 'organization', - email: normalizedEmail, - inviterId: session.user.id, - organizationId, - role: role as 'admin' | 'member', - grants: [], - }) - - const [inviterRow] = await db - .select({ name: user.name, email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - const inviterName = inviterRow?.name || inviterRow?.email || 'A user' - - const emailResult = await sendInvitationEmail({ - invitationId, - token, - kind: 'organization', - email: normalizedEmail, - inviterName, - organizationId, - organizationRole: role as 'admin' | 'member', - grants: [], - }) - - if (!emailResult.success) { - logger.error('Failed to send organization invitation email', { + const { invitationId, token } = await createPendingInvitation({ + kind: 'organization', email: normalizedEmail, - invitationId, - error: emailResult.error, + inviterId: session.user.id, + organizationId, + role: role as 'admin' | 'member', + grants: [], }) - await cancelPendingInvitation(invitationId) - return NextResponse.json( - { error: emailResult.error || 'Failed to send invitation email' }, - { status: 502 } - ) - } - logger.info('Member invitation sent', { - email: normalizedEmail, - organizationId, - invitationId, - role, - }) - - 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: { + const [inviterRow] = await db + .select({ name: user.name, email: user.email }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + const inviterName = inviterRow?.name || inviterRow?.email || 'A user' + + const emailResult = await sendInvitationEmail({ invitationId, + token, + kind: 'organization', email: normalizedEmail, + inviterName, + organizationId, + organizationRole: role as 'admin' | 'member', + grants: [], + }) + + if (!emailResult.success) { + logger.error('Failed to send organization invitation email', { + email: normalizedEmail, + invitationId, + error: emailResult.error, + }) + await cancelPendingInvitation(invitationId) + return NextResponse.json( + { error: emailResult.error || 'Failed to send invitation email' }, + { status: 502 } + ) + } + + logger.info('Member invitation sent', { + email: normalizedEmail, + organizationId, + invitationId, role, - }, - }) - } catch (error) { - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - logger.error('Failed to invite organization member', { - organizationId: (await params).id, - error, - }) + }) + + 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({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + success: true, + message: `Invitation sent to ${normalizedEmail}`, + data: { + invitationId, + email: normalizedEmail, + role, + }, + }) + } catch (error) { + if (error instanceof InvitationsNotAllowedError) { + return NextResponse.json({ error: error.message }, { status: 403 }) + } + 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]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index a392ab50d0d..c1abe4d6d15 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray, 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 { expireStalePendingInvitationsForOrganization } from '@/lib/invitations/core' const logger = createLogger('OrganizationRosterAPI') @@ -21,162 +22,164 @@ interface RosterWorkspaceAccess { permission: 'admin' | 'write' | 'read' } -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 }) - } - - const { id: organizationId } = await params - - const [callerMembership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (!callerMembership) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - if (callerMembership.role !== 'owner' && callerMembership.role !== 'admin') { - return NextResponse.json( - { error: 'Forbidden - Organization admin access required' }, - { status: 403 } - ) - } - - await expireStalePendingInvitationsForOrganization(organizationId) - - const orgWorkspaces = await db - .select({ id: workspace.id, name: workspace.name }) - .from(workspace) - .where(eq(workspace.organizationId, organizationId)) - - const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id) - const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name])) - - const memberRows = await db - .select({ - memberId: member.id, - userId: member.userId, - role: member.role, - createdAt: member.createdAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(eq(member.organizationId, organizationId)) - - const memberUserIds = memberRows.map((row) => row.userId) - - const memberPermissions = - memberUserIds.length > 0 && orgWorkspaceIds.length > 0 - ? await db - .select({ - userId: permissions.userId, - workspaceId: permissions.entityId, - permission: permissions.permissionType, - }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - inArray(permissions.userId, memberUserIds), - inArray(permissions.entityId, orgWorkspaceIds) +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: organizationId } = await params + + const [callerMembership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!callerMembership) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (callerMembership.role !== 'owner' && callerMembership.role !== 'admin') { + return NextResponse.json( + { error: 'Forbidden - Organization admin access required' }, + { status: 403 } + ) + } + + await expireStalePendingInvitationsForOrganization(organizationId) + + const orgWorkspaces = await db + .select({ id: workspace.id, name: workspace.name }) + .from(workspace) + .where(eq(workspace.organizationId, organizationId)) + + const orgWorkspaceIds = orgWorkspaces.map((ws) => ws.id) + const workspaceNameById = new Map(orgWorkspaces.map((ws) => [ws.id, ws.name])) + + const memberRows = await db + .select({ + memberId: member.id, + userId: member.userId, + role: member.role, + createdAt: member.createdAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .where(eq(member.organizationId, organizationId)) + + const memberUserIds = memberRows.map((row) => row.userId) + + const memberPermissions = + memberUserIds.length > 0 && orgWorkspaceIds.length > 0 + ? await db + .select({ + userId: permissions.userId, + workspaceId: permissions.entityId, + permission: permissions.permissionType, + }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + inArray(permissions.userId, memberUserIds), + inArray(permissions.entityId, orgWorkspaceIds) + ) ) - ) - : [] - - const permissionsByUser = new Map() - for (const row of memberPermissions) { - const list = permissionsByUser.get(row.userId) ?? [] - list.push({ - workspaceId: row.workspaceId, - workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', - permission: row.permission, + : [] + + const permissionsByUser = new Map() + for (const row of memberPermissions) { + const list = permissionsByUser.get(row.userId) ?? [] + list.push({ + workspaceId: row.workspaceId, + workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', + permission: row.permission, + }) + permissionsByUser.set(row.userId, list) + } + + const members = memberRows.map((row) => ({ + memberId: row.memberId, + userId: row.userId, + role: row.role, + createdAt: row.createdAt, + name: row.userName, + email: row.userEmail, + image: row.userImage, + workspaces: permissionsByUser.get(row.userId) ?? [], + })) + + const pendingInvitationRows = await db + .select({ + id: invitation.id, + email: invitation.email, + role: invitation.role, + kind: invitation.kind, + createdAt: invitation.createdAt, + expiresAt: invitation.expiresAt, + inviteeName: user.name, + inviteeImage: user.image, + }) + .from(invitation) + .leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`) + .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + + const pendingInvitationIds = pendingInvitationRows.map((row) => row.id) + const pendingGrants = + pendingInvitationIds.length > 0 + ? await db + .select({ + invitationId: invitationWorkspaceGrant.invitationId, + workspaceId: invitationWorkspaceGrant.workspaceId, + permission: invitationWorkspaceGrant.permission, + }) + .from(invitationWorkspaceGrant) + .where(inArray(invitationWorkspaceGrant.invitationId, pendingInvitationIds)) + : [] + + const grantsByInvitation = new Map() + for (const row of pendingGrants) { + const list = grantsByInvitation.get(row.invitationId) ?? [] + list.push({ + workspaceId: row.workspaceId, + workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', + permission: row.permission, + }) + grantsByInvitation.set(row.invitationId, list) + } + + const pendingInvitations = pendingInvitationRows.map((row) => ({ + id: row.id, + email: row.email, + role: row.role, + kind: row.kind, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + inviteeName: row.inviteeName, + inviteeImage: row.inviteeImage, + workspaces: grantsByInvitation.get(row.id) ?? [], + })) + + return NextResponse.json({ + success: true, + data: { + members, + pendingInvitations, + workspaces: orgWorkspaces, + }, }) - permissionsByUser.set(row.userId, list) + } catch (error) { + logger.error('Failed to fetch organization roster', { error }) + return NextResponse.json({ error: 'Failed to fetch organization roster' }, { status: 500 }) } - - const members = memberRows.map((row) => ({ - memberId: row.memberId, - userId: row.userId, - role: row.role, - createdAt: row.createdAt, - name: row.userName, - email: row.userEmail, - image: row.userImage, - workspaces: permissionsByUser.get(row.userId) ?? [], - })) - - const pendingInvitationRows = await db - .select({ - id: invitation.id, - email: invitation.email, - role: invitation.role, - kind: invitation.kind, - createdAt: invitation.createdAt, - expiresAt: invitation.expiresAt, - inviteeName: user.name, - inviteeImage: user.image, - }) - .from(invitation) - .leftJoin(user, sql`lower(${user.email}) = lower(${invitation.email})`) - .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) - - const pendingInvitationIds = pendingInvitationRows.map((row) => row.id) - const pendingGrants = - pendingInvitationIds.length > 0 - ? await db - .select({ - invitationId: invitationWorkspaceGrant.invitationId, - workspaceId: invitationWorkspaceGrant.workspaceId, - permission: invitationWorkspaceGrant.permission, - }) - .from(invitationWorkspaceGrant) - .where(inArray(invitationWorkspaceGrant.invitationId, pendingInvitationIds)) - : [] - - const grantsByInvitation = new Map() - for (const row of pendingGrants) { - const list = grantsByInvitation.get(row.invitationId) ?? [] - list.push({ - workspaceId: row.workspaceId, - workspaceName: workspaceNameById.get(row.workspaceId) ?? 'Workspace', - permission: row.permission, - }) - grantsByInvitation.set(row.invitationId, list) - } - - const pendingInvitations = pendingInvitationRows.map((row) => ({ - id: row.id, - email: row.email, - role: row.role, - kind: row.kind, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - inviteeName: row.inviteeName, - inviteeImage: row.inviteeImage, - workspaces: grantsByInvitation.get(row.id) ?? [], - })) - - return NextResponse.json({ - success: true, - data: { - members, - pendingInvitations, - workspaces: orgWorkspaces, - }, - }) - } catch (error) { - logger.error('Failed to fetch organization roster', { error }) - return NextResponse.json({ error: 'Failed to fetch organization roster' }, { 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 4e227b6c232..0cfba281c62 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -16,6 +16,7 @@ import { import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management' import { isBillingEnabled } from '@/lib/core/config/feature-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OrganizationSeatsAPI') @@ -28,255 +29,257 @@ 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() + + const validation = updateSeatsSchema.safeParse(body) + if (!validation.success) { + const firstError = validation.error.errors[0] + return NextResponse.json({ error: firstError.message }, { status: 400 }) + } + + const { seats: newSeatCount } = validation.data + + // 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) + + if (memberEntry.length === 0) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } + + if (!['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + // 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) - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (subscriptionRecord.length === 0) { + return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) + } - if (!isBillingEnabled) { - return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 }) - } + const orgSubscription = subscriptionRecord[0] - const { id: organizationId } = await params - const body = await request.json() + if (await isOrganizationBillingBlocked(organizationId)) { + return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) + } - const validation = updateSeatsSchema.safeParse(body) - if (!validation.success) { - const firstError = validation.error.errors[0] - return NextResponse.json({ error: firstError.message }, { 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 { seats: newSeatCount } = validation.data + if (!orgSubscription.stripeSubscriptionId) { + return NextResponse.json( + { error: 'No Stripe subscription found for this organization' }, + { status: 400 } + ) + } - // 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 [memberCountRow] = await db + .select({ count: count() }) + .from(member) + .where(eq(member.organizationId, organizationId)) - if (memberEntry.length === 0) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + const [pendingCountRow] = await db + .select({ count: count() }) + .from(invitation) + .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) - if (!['owner', 'admin'].includes(memberEntry[0].role)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } + const memberCount = memberCountRow?.count ?? 0 + const pendingCount = pendingCountRow?.count ?? 0 + const occupiedSeats = memberCount + pendingCount - // 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 (newSeatCount < occupiedSeats) { + return NextResponse.json( + { + error: `Cannot reduce seats below current occupancy (${memberCount} member${memberCount === 1 ? '' : 's'} + ${pendingCount} pending invite${pendingCount === 1 ? '' : 's'}). Cancel pending invites first or remove members.`, + currentMembers: memberCount, + pendingInvitations: pendingCount, + occupiedSeats, + }, + { status: 400 } ) - ) - .limit(1) - - if (subscriptionRecord.length === 0) { - return NextResponse.json({ error: 'No active subscription found' }, { status: 404 }) - } - - const orgSubscription = subscriptionRecord[0] + } + + const currentSeats = orgSubscription.seats || 1 + + // 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, + }, + }) + } - if (await isOrganizationBillingBlocked(organizationId)) { - return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 }) - } + const stripe = requireStripeClient() - // 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 } + // Get the Stripe subscription to find the subscription item ID + const stripeSubscription = await stripe.subscriptions.retrieve( + orgSubscription.stripeSubscriptionId ) - } - if (!orgSubscription.stripeSubscriptionId) { - return NextResponse.json( - { error: 'No Stripe subscription found for this organization' }, - { status: 400 } - ) - } + if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { + return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) + } - const [memberCountRow] = await db - .select({ count: count() }) - .from(member) - .where(eq(member.organizationId, organizationId)) + // Find the subscription item (there should be only one for team plans) + const subscriptionItem = stripeSubscription.items.data[0] - const [pendingCountRow] = await db - .select({ count: count() }) - .from(invitation) - .where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending'))) + if (!subscriptionItem) { + return NextResponse.json( + { error: 'No subscription item found in Stripe subscription' }, + { status: 500 } + ) + } - const memberCount = memberCountRow?.count ?? 0 - const pendingCount = pendingCountRow?.count ?? 0 - const occupiedSeats = memberCount + pendingCount + logger.info('Updating Stripe subscription quantity', { + organizationId, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + subscriptionItemId: subscriptionItem.id, + currentSeats, + newSeatCount, + userId: session.user.id, + }) - if (newSeatCount < occupiedSeats) { - return NextResponse.json( + const updatedSubscription = await stripe.subscriptions.update( + orgSubscription.stripeSubscriptionId, { - error: `Cannot reduce seats below current occupancy (${memberCount} member${memberCount === 1 ? '' : 's'} + ${pendingCount} pending invite${pendingCount === 1 ? '' : 's'}). Cancel pending invites first or remove members.`, - currentMembers: memberCount, - pendingInvitations: pendingCount, - occupiedSeats, + items: [ + { + id: subscriptionItem.id, + quantity: newSeatCount, + }, + ], + proration_behavior: 'always_invoice', }, - { status: 400 } + { idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` } ) - } - const currentSeats = orgSubscription.seats || 1 + await syncSeatsFromStripeQuantity( + orgSubscription.id, + orgSubscription.seats, + updatedSubscription.items.data[0]?.quantity ?? newSeatCount + ) + + 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 + ? toNumber(toDecimal(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('Successfully updated seat count', { + organizationId, + stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + oldSeats: currentSeats, + newSeats: newSeatCount, + updatedBy: session.user.id, + prorationBehavior: 'always_invoice', + }) - // If no change, return early - if (newSeatCount === currentSeats) { return NextResponse.json({ success: true, - message: 'No change in seat count', + 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: currentSeats, - stripeSubscriptionId: orgSubscription.stripeSubscriptionId, + seats: newSeatCount, + previousSeats: currentSeats, + stripeSubscriptionId: updatedSubscription.id, + stripeStatus: updatedSubscription.status, }, }) - } - - const stripe = requireStripeClient() - - // Get the Stripe subscription to find the subscription item ID - const stripeSubscription = await stripe.subscriptions.retrieve( - orgSubscription.stripeSubscriptionId - ) - - if (!hasUsableSubscriptionStatus(stripeSubscription.status)) { - return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 }) - } - - // Find the subscription item (there should be only one for team plans) - const subscriptionItem = stripeSubscription.items.data[0] - - if (!subscriptionItem) { - return NextResponse.json( - { error: 'No subscription item found in Stripe subscription' }, - { status: 500 } - ) - } + } 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, + }) - logger.info('Updating Stripe subscription quantity', { - organizationId, - stripeSubscriptionId: orgSubscription.stripeSubscriptionId, - subscriptionItemId: subscriptionItem.id, - currentSeats, - newSeatCount, - userId: session.user.id, - }) - - const updatedSubscription = await stripe.subscriptions.update( - orgSubscription.stripeSubscriptionId, - { - items: [ + return NextResponse.json( { - id: subscriptionItem.id, - quantity: newSeatCount, + error: stripeError.message || 'Failed to update seats in Stripe', + code: stripeError.code, }, - ], - proration_behavior: 'always_invoice', - }, - { idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` } - ) - - await syncSeatsFromStripeQuantity( - orgSubscription.id, - orgSubscription.seats, - updatedSubscription.items.data[0]?.quantity ?? newSeatCount - ) - - 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 - ? toNumber(toDecimal(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, - }) - } + { status: 400 } + ) + } - 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', { + logger.error('Failed to update organization seats', { organizationId, - type: stripeError.type, - code: stripeError.code, - message: stripeError.message, + error, }) - return NextResponse.json( - { - error: stripeError.message || 'Failed to update seats in Stripe', - code: stripeError.code, - }, - { status: 400 } - ) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - logger.error('Failed to update organization seats', { - organizationId, - error, - }) - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts index 44c2bf15701..4203f0c5e88 100644 --- a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -11,6 +11,7 @@ import { removeUserFromOrganization, transferOrganizationOwnership, } from '@/lib/billing/organizations/membership' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TransferOwnershipAPI') @@ -19,126 +20,190 @@ const transferOwnershipSchema = z.object({ alsoLeave: z.boolean().optional().default(false), }) -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 }) - } - - const { id: organizationId } = await params - const body = await request.json().catch(() => ({})) - const validation = transferOwnershipSchema.safeParse(body) - if (!validation.success) { - return NextResponse.json( - { error: validation.error.errors[0]?.message ?? 'Invalid request' }, - { status: 400 } - ) - } - - const { newOwnerUserId, alsoLeave } = validation.data - - if (newOwnerUserId === session.user.id) { - return NextResponse.json( - { error: 'New owner must differ from current owner' }, - { status: 400 } - ) - } - - const [currentOwnerMember] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (!currentOwnerMember) { - return NextResponse.json( - { error: 'You are not a member of this organization' }, - { status: 403 } - ) - } - - if (currentOwnerMember.role !== 'owner') { - return NextResponse.json( - { error: 'Only the current owner can transfer ownership' }, - { status: 403 } - ) - } - - const [targetMember] = await db - .select({ - id: member.id, - role: member.role, - email: user.email, - name: user.name, +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 { id: organizationId } = await params + const body = await request.json().catch(() => ({})) + const validation = transferOwnershipSchema.safeParse(body) + if (!validation.success) { + return NextResponse.json( + { error: validation.error.errors[0]?.message ?? 'Invalid request' }, + { status: 400 } + ) + } + + const { newOwnerUserId, alsoLeave } = validation.data + + if (newOwnerUserId === session.user.id) { + return NextResponse.json( + { error: 'New owner must differ from current owner' }, + { status: 400 } + ) + } + + const [currentOwnerMember] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!currentOwnerMember) { + return NextResponse.json( + { error: 'You are not a member of this organization' }, + { status: 403 } + ) + } + + if (currentOwnerMember.role !== 'owner') { + return NextResponse.json( + { error: 'Only the current owner can transfer ownership' }, + { 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, newOwnerUserId))) + .limit(1) + + if (!targetMember) { + return NextResponse.json( + { error: 'Target user is not a member of this organization' }, + { status: 400 } + ) + } + + const transferResult = await transferOrganizationOwnership({ + organizationId, + currentOwnerUserId: session.user.id, + newOwnerUserId, }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId))) - .limit(1) - - if (!targetMember) { - return NextResponse.json( - { error: 'Target user is not a member of this organization' }, - { status: 400 } - ) - } - - const transferResult = await transferOrganizationOwnership({ - organizationId, - currentOwnerUserId: session.user.id, - newOwnerUserId, - }) - - if (!transferResult.success) { - return NextResponse.json( - { error: transferResult.error ?? 'Failed to transfer ownership' }, - { status: 500 } - ) - } - recordAudit({ - workspaceId: null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: AuditAction.ORG_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - description: `Transferred ownership to ${targetMember.email}`, - metadata: { - targetUserId: newOwnerUserId, - targetEmail: targetMember.email ?? undefined, - targetName: targetMember.name ?? undefined, - workspacesReassigned: transferResult.workspacesReassigned, - billedAccountReassigned: transferResult.billedAccountReassigned, - overageMigrated: transferResult.overageMigrated, - billingBlockInherited: transferResult.billingBlockInherited, - }, - request, - }) - - if (!alsoLeave) { - return NextResponse.json({ - success: true, - transferred: true, - left: false, - details: { + if (!transferResult.success) { + return NextResponse.json( + { error: transferResult.error ?? 'Failed to transfer ownership' }, + { status: 500 } + ) + } + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: `Transferred ownership to ${targetMember.email}`, + metadata: { + targetUserId: newOwnerUserId, + targetEmail: targetMember.email ?? undefined, + targetName: targetMember.name ?? undefined, workspacesReassigned: transferResult.workspacesReassigned, billedAccountReassigned: transferResult.billedAccountReassigned, overageMigrated: transferResult.overageMigrated, billingBlockInherited: transferResult.billingBlockInherited, }, + request, + }) + + if (!alsoLeave) { + return NextResponse.json({ + success: true, + transferred: true, + left: false, + details: { + workspacesReassigned: transferResult.workspacesReassigned, + billedAccountReassigned: transferResult.billedAccountReassigned, + overageMigrated: transferResult.overageMigrated, + billingBlockInherited: transferResult.billingBlockInherited, + }, + }) + } + + const [selfMember] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!selfMember) { + return NextResponse.json({ + success: true, + transferred: true, + left: true, + details: { + workspacesReassigned: transferResult.workspacesReassigned, + billedAccountReassigned: transferResult.billedAccountReassigned, + overageMigrated: transferResult.overageMigrated, + billingBlockInherited: transferResult.billingBlockInherited, + }, + }) + } + + const removeResult = await removeUserFromOrganization({ + userId: session.user.id, + organizationId, + memberId: selfMember.id, }) - } - const [selfMember] = await db - .select({ id: member.id }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + if (!removeResult.success) { + logger.error('Transfer succeeded but self-removal failed', { + organizationId, + userId: session.user.id, + error: removeResult.error, + }) + return NextResponse.json( + { + success: true, + transferred: true, + left: false, + warning: removeResult.error ?? 'Failed to leave after transfer', + }, + { status: 207 } + ) + } + + try { + await setActiveOrganizationForCurrentSession(null) + } catch (clearError) { + logger.warn('Failed to clear active organization after transfer-and-leave', { + userId: session.user.id, + organizationId, + error: clearError, + }) + } + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + description: 'Left the organization after transferring ownership', + metadata: { + targetUserId: session.user.id, + wasSelfRemoval: true, + followedOwnershipTransfer: true, + }, + request, + }) - if (!selfMember) { return NextResponse.json({ success: true, transferred: true, @@ -148,77 +213,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ billedAccountReassigned: transferResult.billedAccountReassigned, overageMigrated: transferResult.overageMigrated, billingBlockInherited: transferResult.billingBlockInherited, + billingActions: removeResult.billingActions, }, }) - } - - const removeResult = await removeUserFromOrganization({ - userId: session.user.id, - organizationId, - memberId: selfMember.id, - }) - - if (!removeResult.success) { - logger.error('Transfer succeeded but self-removal failed', { - organizationId, - userId: session.user.id, - error: removeResult.error, - }) - return NextResponse.json( - { - success: true, - transferred: true, - left: false, - warning: removeResult.error ?? 'Failed to leave after transfer', - }, - { status: 207 } - ) - } - - try { - await setActiveOrganizationForCurrentSession(null) - } catch (clearError) { - logger.warn('Failed to clear active organization after transfer-and-leave', { - userId: session.user.id, - organizationId, - error: clearError, + } catch (error) { + logger.error('Failed to transfer organization ownership', { + organizationId: (await params).id, + error, }) + return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 }) } - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - action: AuditAction.ORG_MEMBER_REMOVED, - resourceType: AuditResourceType.ORGANIZATION, - resourceId: organizationId, - description: 'Left the organization after transferring ownership', - metadata: { - targetUserId: session.user.id, - wasSelfRemoval: true, - followedOwnershipTransfer: true, - }, - request, - }) - - return NextResponse.json({ - success: true, - transferred: true, - left: true, - details: { - workspacesReassigned: transferResult.workspacesReassigned, - billedAccountReassigned: transferResult.billedAccountReassigned, - overageMigrated: transferResult.overageMigrated, - billingBlockInherited: transferResult.billingBlockInherited, - billingActions: removeResult.billingActions, - }, - }) - } catch (error) { - logger.error('Failed to transfer organization ownership', { - organizationId: (await params).id, - error, - }) - return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 }) } -} +) diff --git a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts index 54d385c313c..fc54802db7f 100644 --- a/apps/sim/app/api/organizations/[id]/whitelabel/route.ts +++ b/apps/sim/app/api/organizations/[id]/whitelabel/route.ts @@ -9,6 +9,7 @@ import { getSession } from '@/lib/auth' import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { HEX_COLOR_REGEX } from '@/lib/branding' import type { OrganizationWhitelabelSettings } from '@/lib/branding/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('WhitelabelAPI') @@ -57,157 +58,163 @@ const updateWhitelabelSchema = z.object({ * Returns the organization's whitelabel settings. * Accessible by any member of the 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({ id: member.id }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) + const [memberEntry] = await db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) - if (!memberEntry) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - const [org] = await db - .select({ whitelabelSettings: organization.whitelabelSettings }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const [org] = await db + .select({ whitelabelSettings: organization.whitelabelSettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - 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({ - success: true, - data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings, - }) - } catch (error) { - logger.error('Failed to get whitelabel settings', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json({ + success: true, + data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings, + }) + } catch (error) { + logger.error('Failed to get whitelabel settings', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) /** * PUT /api/organizations/[id]/whitelabel * Updates the organization's whitelabel settings. * Requires enterprise plan and owner/admin role. */ -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 (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const { id: organizationId } = await params + const { id: organizationId } = await params - const body = await request.json() - const parsed = updateWhitelabelSchema.safeParse(body) + const body = await request.json() + const parsed = updateWhitelabelSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, - { status: 400 } - ) - } + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, + { status: 400 } + ) + } - const [memberEntry] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (!memberEntry) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } + const [memberEntry] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (!memberEntry) { + return NextResponse.json( + { error: 'Forbidden - Not a member of this organization' }, + { status: 403 } + ) + } - if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') { - return NextResponse.json( - { error: 'Forbidden - Only organization owners and admins can update whitelabel settings' }, - { status: 403 } - ) - } + if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') { + return NextResponse.json( + { + error: 'Forbidden - Only organization owners and admins can update whitelabel settings', + }, + { status: 403 } + ) + } - const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId) + const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId) - if (!hasEnterprisePlan) { - return NextResponse.json( - { error: 'Whitelabeling is available on Enterprise plans only' }, - { status: 403 } - ) - } + if (!hasEnterprisePlan) { + return NextResponse.json( + { error: 'Whitelabeling is available on Enterprise plans only' }, + { status: 403 } + ) + } - const [currentOrg] = await db - .select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const [currentOrg] = await db + .select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!currentOrg) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } + if (!currentOrg) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {} - const incoming = parsed.data + const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {} + const incoming = parsed.data - const merged: OrganizationWhitelabelSettings = { ...current } + const merged: OrganizationWhitelabelSettings = { ...current } - for (const key of Object.keys(incoming) as Array) { - const value = incoming[key] - if (value === null) { - delete merged[key as keyof OrganizationWhitelabelSettings] - } else if (value !== undefined) { - ;(merged as Record)[key] = value + for (const key of Object.keys(incoming) as Array) { + const value = incoming[key] + if (value === null) { + delete merged[key as keyof OrganizationWhitelabelSettings] + } else if (value !== undefined) { + ;(merged as Record)[key] = value + } } - } - const [updated] = await db - .update(organization) - .set({ whitelabelSettings: merged, updatedAt: new Date() }) - .where(eq(organization.id, organizationId)) - .returning({ whitelabelSettings: organization.whitelabelSettings }) + const [updated] = await db + .update(organization) + .set({ whitelabelSettings: merged, updatedAt: new Date() }) + .where(eq(organization.id, organizationId)) + .returning({ whitelabelSettings: organization.whitelabelSettings }) - if (!updated) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } + if (!updated) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } - 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: currentOrg.name, - description: 'Updated organization whitelabel settings', - metadata: { changes: Object.keys(incoming) }, - request, - }) - - return NextResponse.json({ - success: true, - data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings, - }) - } catch (error) { - logger.error('Failed to update whitelabel settings', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + 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: currentOrg.name, + description: 'Updated organization whitelabel settings', + metadata: { changes: Object.keys(incoming) }, + request, + }) + + return NextResponse.json({ + success: true, + data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings, + }) + } catch (error) { + logger.error('Failed to update whitelabel settings', { error }) + 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 6bbcc31ab88..13b8e041e98 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -16,6 +16,7 @@ import { } from '@/lib/billing/organizations/create-organization' import { isOrgPlan } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { attachOwnedWorkspacesToOrganization, WorkspaceOrganizationMembershipConflictError, @@ -23,7 +24,7 @@ import { const logger = createLogger('OrganizationsAPI') -export async function GET() { +export const GET = withRouteHandler(async () => { try { const session = await getSession() @@ -64,9 +65,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() @@ -289,4 +290,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 8617746a553..7d8c9eba15c 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 { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' +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 83cd4a0b17a..d1e38849bce 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 { 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' 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 7cab684f043..471caaf73ca 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, @@ -77,205 +78,213 @@ 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 }) - } - - return NextResponse.json({ - permissionGroup: { - ...result.group, - config: parsePermissionGroupConfig(result.group.config), - }, - }) -} - -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 }) - } - - 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 { 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.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } + return NextResponse.json({ + permissionGroup: { + ...result.group, + config: parsePermissionGroupConfig(result.group.config), + }, + }) + } +) - const body = await req.json() - const updates = updateSchema.parse(body) +export const PUT = withRouteHandler( + async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const session = await getSession() - 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) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params - if (existingGroup.length > 0 && existingGroup[0].id !== id) { + try { + const hasAccess = await hasAccessControlAccess(session.user.id) + if (!hasAccess) { return NextResponse.json( - { error: 'A permission group with this name already exists' }, - { status: 409 } + { error: 'Access Control is an Enterprise feature' }, + { status: 403 } ) } - } - const currentConfig = parsePermissionGroupConfig(result.group.config) - const newConfig: PermissionGroupConfig = updates.config - ? { ...currentConfig, ...updates.config } - : currentConfig + const result = await getPermissionGroupWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - const now = new Date() + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - await db.transaction(async (tx) => { - if (updates.autoAddNewMembers === true) { - await tx - .update(permissionGroup) - .set({ autoAddNewMembers: false, updatedAt: now }) + 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.autoAddNewMembers, true) + eq(permissionGroup.name, updates.name) ) ) + .limit(1) + + if (existingGroup.length > 0 && existingGroup[0].id !== id) { + return NextResponse.json( + { error: 'A permission group with this name already exists' }, + { status: 409 } + ) + } } - await tx - .update(permissionGroup) - .set({ - ...(updates.name !== undefined && { name: updates.name }), - ...(updates.description !== undefined && { description: updates.description }), - ...(updates.autoAddNewMembers !== undefined && { - autoAddNewMembers: updates.autoAddNewMembers, - }), - config: newConfig, - updatedAt: now, - }) - .where(eq(permissionGroup.id, id)) - }) + const currentConfig = parsePermissionGroupConfig(result.group.config) + const newConfig: PermissionGroupConfig = updates.config + ? { ...currentConfig, ...updates.config } + : currentConfig + + const now = new Date() + + await db.transaction(async (tx) => { + if (updates.autoAddNewMembers === true) { + await tx + .update(permissionGroup) + .set({ autoAddNewMembers: false, updatedAt: now }) + .where( + and( + eq(permissionGroup.organizationId, result.group.organizationId), + eq(permissionGroup.autoAddNewMembers, true) + ) + ) + } - 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}"`, - metadata: { - organizationId: result.group.organizationId, - updatedFields: Object.keys(updates).filter( - (k) => updates[k as keyof typeof updates] !== undefined - ), - }, - request: req, - }) + await tx + .update(permissionGroup) + .set({ + ...(updates.name !== undefined && { name: updates.name }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.autoAddNewMembers !== undefined && { + autoAddNewMembers: updates.autoAddNewMembers, + }), + config: newConfig, + updatedAt: now, + }) + .where(eq(permissionGroup.id, id)) + }) + + const [updated] = await db + .select() + .from(permissionGroup) + .where(eq(permissionGroup.id, id)) + .limit(1) - 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 }) + 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}"`, + metadata: { + organizationId: result.group.organizationId, + updatedFields: Object.keys(updates).filter( + (k) => updates[k as keyof typeof updates] !== undefined + ), + }, + 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 }) + } + 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() - - 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 - - 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) - await db.transaction(async (tx) => { - await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.permissionGroupId, id)) - await tx.delete(permissionGroup).where(eq(permissionGroup.id, id)) - }) + if (!result) { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } - 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}"`, - metadata: { organizationId: result.group.organizationId }, - request: req, - }) + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } - 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 }) + await db.transaction(async (tx) => { + await tx + .delete(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, id)) + await tx.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}"`, + metadata: { organizationId: result.group.organizationId }, + 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 }) + } } -} +) diff --git a/apps/sim/app/api/permission-groups/route.ts b/apps/sim/app/api/permission-groups/route.ts index 1d5e4d6a42c..69157fd9afd 100644 --- a/apps/sim/app/api/permission-groups/route.ts +++ b/apps/sim/app/api/permission-groups/route.ts @@ -8,6 +8,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 { DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, @@ -50,7 +51,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) { @@ -108,9 +109,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) { @@ -227,4 +228,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 4a676f7e7d9..eccdb717279 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 { getOllamaUrl } from '@/lib/core/utils/urls' +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 = getOllamaUrl() /** * 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 7e6522fee14..06e6a4c28ad 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -6,6 +6,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, @@ -22,7 +23,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() @@ -268,7 +269,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: toError(error).message }, { 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 4dc5af81f5f..e5a2c7cd251 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -7,6 +7,7 @@ import { getJobQueue } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' +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' @@ -38,278 +39,284 @@ function getStoredSnapshotConfig(pausedExecution: { executionSnapshot: unknown } } } -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 - 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 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', - executionId: resumeExecutionId, - requestId, - checkRateLimit: false, - checkDeployment: false, - skipUsageLimits: true, - 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', + executionId: resumeExecutionId, + requestId, + checkRateLimit: false, + checkDeployment: false, + skipUsageLimits: true, + 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, - } - - const isApiCaller = access.auth?.authType === AuthType.API_KEY - const snapshotConfig = isApiCaller ? getStoredSnapshotConfig(enqueueResult.pausedExecution) : {} - const executionMode = isApiCaller ? (snapshotConfig.executionMode ?? 'sync') : undefined - - if (isApiCaller && executionMode === 'stream') { - const stream = await createStreamingResponse({ - requestId, - streamConfig: { - selectedOutputs: snapshotConfig.selectedOutputs, - timeoutMs: preprocessResult.executionTimeout?.sync, - }, - executionId: enqueueResult.resumeExecutionId, - executeFn: async ({ onStream, onBlockComplete, abortSignal }) => - PauseResumeManager.startResumeExecution({ - ...resumeArgs, - onStream, - onBlockComplete, - abortSignal, - }), - }) - - return new NextResponse(stream, { - headers: { - ...SSE_HEADERS, - 'X-Execution-Id': enqueueResult.resumeExecutionId, - }, + try { + const enqueueResult = await PauseResumeManager.enqueueOrStartResume({ + executionId, + contextId, + resumeInput, + userId, }) - } - if (isApiCaller && executionMode === 'sync') { - const result = await PauseResumeManager.startResumeExecution(resumeArgs) + 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.', + }) + } - 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, + await setExecutionMeta(enqueueResult.resumeExecutionId, { + status: 'active', + userId, + workflowId, }) - } - if (isApiCaller && executionMode === 'async') { - const resumePayload: ResumeExecutionPayload = { + const resumeArgs = { resumeEntryId: enqueueResult.resumeEntryId, resumeExecutionId: enqueueResult.resumeExecutionId, - pausedExecutionId: enqueueResult.pausedExecution.id, + pausedExecution: enqueueResult.pausedExecution, contextId: enqueueResult.contextId, resumeInput: enqueueResult.resumeInput, userId: enqueueResult.userId, - workflowId, - parentExecutionId: executionId, } - let jobId: string - try { - const jobQueue = await getJobQueue() - jobId = await jobQueue.enqueue('resume-execution', resumePayload, { - metadata: { workflowId, workspaceId: workflow.workspaceId, userId }, + const isApiCaller = access.auth?.authType === AuthType.API_KEY + const snapshotConfig = isApiCaller + ? getStoredSnapshotConfig(enqueueResult.pausedExecution) + : {} + const executionMode = isApiCaller ? (snapshotConfig.executionMode ?? 'sync') : undefined + + if (isApiCaller && executionMode === 'stream') { + const stream = await createStreamingResponse({ + requestId, + streamConfig: { + selectedOutputs: snapshotConfig.selectedOutputs, + timeoutMs: preprocessResult.executionTimeout?.sync, + }, + executionId: enqueueResult.resumeExecutionId, + executeFn: async ({ onStream, onBlockComplete, abortSignal }) => + PauseResumeManager.startResumeExecution({ + ...resumeArgs, + onStream, + onBlockComplete, + abortSignal, + }), }) - logger.info('Enqueued async resume execution', { - jobId, - resumeExecutionId: enqueueResult.resumeExecutionId, + + return new NextResponse(stream, { + headers: { + ...SSE_HEADERS, + 'X-Execution-Id': enqueueResult.resumeExecutionId, + }, }) - } catch (dispatchError) { - logger.error('Failed to dispatch async resume execution', { - error: toError(dispatchError).message, - resumeExecutionId: enqueueResult.resumeExecutionId, + } + + if (isApiCaller && executionMode === 'sync') { + 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, }) + } + + if (isApiCaller && executionMode === 'async') { + const resumePayload: ResumeExecutionPayload = { + resumeEntryId: enqueueResult.resumeEntryId, + resumeExecutionId: enqueueResult.resumeExecutionId, + pausedExecutionId: enqueueResult.pausedExecution.id, + contextId: enqueueResult.contextId, + resumeInput: enqueueResult.resumeInput, + userId: enqueueResult.userId, + workflowId, + parentExecutionId: executionId, + } + + let jobId: string + try { + const jobQueue = await getJobQueue() + jobId = await jobQueue.enqueue('resume-execution', resumePayload, { + metadata: { workflowId, workspaceId: workflow.workspaceId, userId }, + }) + logger.info('Enqueued async resume execution', { + jobId, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + } catch (dispatchError) { + logger.error('Failed to dispatch async resume execution', { + error: toError(dispatchError).message, + resumeExecutionId: enqueueResult.resumeExecutionId, + }) + return NextResponse.json( + { error: 'Failed to queue resume execution. Please try again.' }, + { status: 503 } + ) + } + return NextResponse.json( - { error: 'Failed to queue resume execution. Please try again.' }, - { status: 503 } + { + success: true, + async: true, + jobId, + executionId: enqueueResult.resumeExecutionId, + message: 'Resume execution queued', + statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`, + }, + { status: 202 } ) } - return NextResponse.json( - { - success: true, - async: true, - jobId, - executionId: enqueueResult.resumeExecutionId, - message: 'Resume execution queued', - statusUrl: `${getBaseUrl()}/api/jobs/${jobId}`, - }, - { status: 202 } - ) - } + PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { + logger.error('Failed to start resume execution', { + workflowId, + parentExecutionId: executionId, + resumeExecutionId: enqueueResult.resumeExecutionId, + error, + }) + }) - PauseResumeManager.startResumeExecution(resumeArgs).catch((error) => { - logger.error('Failed to start resume execution', { + return NextResponse.json({ + status: 'started', + executionId: enqueueResult.resumeExecutionId, + message: 'Resume execution started.', + }) + } 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', { + 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 - - 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 d05514a8837..73155a15892 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' @@ -106,42 +107,154 @@ 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, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: scheduleId, + resourceName: schedule.jobTitle ?? undefined, + description: `Disabled schedule "${schedule.jobTitle ?? scheduleId}"`, + metadata: { + operation: 'disable', + sourceType: schedule.sourceType, + previousStatus: schedule.status, + }, + 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, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: scheduleId, + resourceName: schedule.jobTitle ?? undefined, + description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`, + metadata: { + operation: 'update', + updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'), + }, + 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, @@ -152,185 +265,74 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ resourceType: AuditResourceType.SCHEDULE, resourceId: scheduleId, resourceName: schedule.jobTitle ?? undefined, - description: `Disabled schedule "${schedule.jobTitle ?? scheduleId}"`, + description: `Reactivated schedule "${schedule.jobTitle ?? scheduleId}"`, metadata: { - operation: 'disable', + operation: 'reactivate', sourceType: schedule.sourceType, - previousStatus: schedule.status, + 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, actorId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, - action: AuditAction.SCHEDULE_UPDATED, + action: AuditAction.SCHEDULE_DELETED, resourceType: AuditResourceType.SCHEDULE, resourceId: scheduleId, resourceName: schedule.jobTitle ?? undefined, - description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`, + description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} "${schedule.jobTitle ?? scheduleId}"`, metadata: { - operation: 'update', - updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'), + sourceType: schedule.sourceType, + cronExpression: schedule.cronExpression, + timezone: schedule.timezone, }, request, }) - return NextResponse.json({ message: 'Schedule updated successfully' }) + 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 }) } - - // 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, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SCHEDULE_UPDATED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - resourceName: schedule.jobTitle ?? undefined, - description: `Reactivated schedule "${schedule.jobTitle ?? scheduleId}"`, - metadata: { - operation: 'reactivate', - sourceType: schedule.sourceType, - 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 }) - } - - 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, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.SCHEDULE_DELETED, - resourceType: AuditResourceType.SCHEDULE, - resourceId: scheduleId, - resourceName: schedule.jobTitle ?? undefined, - description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} "${schedule.jobTitle ?? scheduleId}"`, - metadata: { - sourceType: schedule.sourceType, - cronExpression: schedule.cronExpression, - timezone: schedule.timezone, - }, - 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 d4c17f95a62..584425196f5 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeJobInline, executeScheduleJob, @@ -29,7 +30,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()}`) @@ -215,4 +216,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 2f12c23863c..2deecfebdfb 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -7,6 +7,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 { validateCronExpression } from '@/lib/workflows/schedules/utils' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -21,7 +22,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') @@ -116,7 +117,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) @@ -191,7 +192,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 { @@ -314,4 +315,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/settings/allowed-providers/route.ts b/apps/sim/app/api/settings/allowed-providers/route.ts index 2880c9eca08..81b0b66b11c 100644 --- a/apps/sim/app/api/settings/allowed-providers/route.ts +++ b/apps/sim/app/api/settings/allowed-providers/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getBlacklistedProvidersFromEnv } 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({ blacklistedProviders: getBlacklistedProvidersFromEnv(), }) -} +}) diff --git a/apps/sim/app/api/settings/voice/route.ts b/apps/sim/app/api/settings/voice/route.ts index 65bcacb1804..a7c9af35cea 100644 --- a/apps/sim/app/api/settings/voice/route.ts +++ b/apps/sim/app/api/settings/voice/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { hasSTTService } from '@/lib/speech/config' /** @@ -6,6 +7,6 @@ import { hasSTTService } from '@/lib/speech/config' * Unauthenticated — the response is a single boolean, * not sensitive data, and deployed chat visitors need it. */ -export async function GET() { +export const GET = withRouteHandler(async () => { return NextResponse.json({ sttAvailable: hasSTTService() }) -} +}) 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 f1db74a3cce..069d5dcbfb8 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 { @@ -140,10 +141,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') @@ -210,4 +211,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/speech/token/route.ts b/apps/sim/app/api/speech/token/route.ts index b4a5835b9eb..c662cbdc4c2 100644 --- a/apps/sim/app/api/speech/token/route.ts +++ b/apps/sim/app/api/speech/token/route.ts @@ -11,6 +11,7 @@ import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-f import { RateLimiter } from '@/lib/core/rate-limiter' import { validateAuthToken } from '@/lib/core/security/deployment' import { getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SpeechTokenAPI') @@ -70,7 +71,7 @@ async function validateChatAuth( } } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { try { const body = await request.json().catch(() => ({})) const chatId = body?.chatId as string | undefined @@ -171,4 +172,4 @@ export async function POST(request: NextRequest) { logger.error('Speech token error:', error) return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/stars/route.ts b/apps/sim/app/api/stars/route.ts index 9f10c5db9f6..9d2c9bb15de 100644 --- a/apps/sim/app/api/stars/route.ts +++ b/apps/sim/app/api/stars/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('StarsRoute') @@ -10,7 +11,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', { @@ -35,4 +36,4 @@ export async function GET() { logger.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 cbdb7e7d2cd..72a9cf0af80 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 { generateId } from '@sim/utils/id' import { and, eq, isNull } 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 { 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]/import-csv/route.ts b/apps/sim/app/api/table/[tableId]/import-csv/route.ts index 20ef2dac2c4..771f145e8b4 100644 --- a/apps/sim/app/api/table/[tableId]/import-csv/route.ts +++ b/apps/sim/app/api/table/[tableId]/import-csv/route.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' 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 { batchInsertRows, buildAutoMapping, @@ -26,7 +27,7 @@ interface RouteParams { params: Promise<{ tableId: string }> } -export async function POST(request: NextRequest, { params }: RouteParams) { +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { const requestId = generateRequestId() const { tableId } = await params @@ -265,4 +266,4 @@ export async function POST(request: NextRequest, { params }: RouteParams) { { status: isClientError ? 400 : 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 fca864c8753..e8139035cf2 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -3,65 +3,65 @@ 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 +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 }) - } + 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 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 }) - } + 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) + await restoreTable(tableId, requestId) - logger.info(`[${requestId}] Restored table ${tableId}`) + 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}"`, - metadata: { - tableName: table.name, + recordAudit({ workspaceId: table.workspaceId, - }, - request, - }) + 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}"`, + metadata: { + tableName: table.name, + workspaceId: table.workspaceId, + }, + request, + }) - return NextResponse.json({ success: true }) - } catch (error) { - if (error instanceof TableConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }) - } + 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 } - ) + 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 6ed71100944..f5a9df02593 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -7,6 +7,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' @@ -31,7 +32,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 @@ -104,10 +105,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 @@ -192,10 +193,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 @@ -249,4 +250,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 762cc84ef7b..151437fcdd8 100644 --- a/apps/sim/app/api/table/[tableId]/rows/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/route.ts @@ -7,6 +7,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, @@ -201,375 +202,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 = toError(error).message - - 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 = toError(error).message -/** 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 = toError(error).message + 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 = toError(error).message + + 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 ) @@ -577,133 +612,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 = toError(error).message - 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 = toError(error).message + 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 = toError(error).message + 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 = toError(error).message + + 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 174bb71cea7..7acf22085ae 100644 --- a/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/upsert/route.ts @@ -4,6 +4,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' @@ -21,89 +22,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 = toError(error).message + 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 = toError(error).message + + // 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 3539cdeafb9..42127780360 100644 --- a/apps/sim/app/api/table/import-csv/route.ts +++ b/apps/sim/app/api/table/import-csv/route.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' 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 { batchInsertRows, CSV_MAX_BATCH_SIZE, @@ -22,7 +23,7 @@ import { normalizeColumn } from '@/app/api/table/utils' const logger = createLogger('TableImportCSV') -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -140,4 +141,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 82c73fffa0f..d810fcf2f57 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,240 +111,250 @@ 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}"`, - metadata: { - templateName: name ?? template.name, - updatedFields: Object.keys(validationResult.data).filter( - (k) => validationResult.data[k as keyof typeof validationResult.data] !== undefined - ), - statusChange: status !== undefined ? { from: template.status, to: status } : undefined, - stateUpdated: updateState || false, - workflowId: template.workflowId || undefined, - }, - 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}"`, + metadata: { + templateName: name ?? template.name, + updatedFields: Object.keys(validationResult.data).filter( + (k) => validationResult.data[k as keyof typeof validationResult.data] !== undefined + ), + statusChange: status !== undefined ? { from: template.status, to: status } : undefined, + stateUpdated: updateState || false, + workflowId: template.workflowId || undefined, + }, + 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)) - - 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}"`, - metadata: { - templateName: template.name, - workflowId: template.workflowId || undefined, - creatorId: template.creatorId || undefined, - status: template.status, - tags: template.tags, - }, - request, - }) + await db.delete(templates).where(eq(templates.id, 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}"`, + metadata: { + templateName: template.name, + workflowId: template.workflowId || undefined, + creatorId: template.creatorId || undefined, + status: template.status, + tags: template.tags, + }, + 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 8cc52518da4..c31b598619c 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 { and, eq, sql } 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('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 d18187df7ee..35b1d84507f 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 { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +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 67755e77c7f..3e4db735db0 100644 --- a/apps/sim/app/api/templates/approved/sanitized/route.ts +++ b/apps/sim/app/api/templates/approved/sanitized/route.ts @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalApiKey } from '@/lib/copilot/request/http' 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') @@ -17,7 +18,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 { @@ -124,10 +125,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`) @@ -138,4 +139,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 a1b22b15de6..ffca21baf97 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -14,6 +14,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, 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 { @@ -376,4 +377,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 eb4bc5b056e..0cc2553e898 100644 --- a/apps/sim/app/api/tools/a2a/send-message/route.ts +++ b/apps/sim/app/api/tools/a2a/send-message/route.ts @@ -8,6 +8,7 @@ import { createA2AClient, extractTextContent, isTerminalState } from '@/lib/a2a/ 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' @@ -30,7 +31,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 { @@ -225,4 +226,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/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts index db55283d82a..792d3df1235 100644 --- a/apps/sim/app/api/tools/agiloft/attach/route.ts +++ b/apps/sim/app/api/tools/agiloft/attach/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } 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' 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 AgiloftAttachSchema = z.object({ fileName: z.string().optional().nullable(), }) -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/agiloft/retrieve/route.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.ts index 44f16ef81cc..f7154d8d7f8 100644 --- a/apps/sim/app/api/tools/agiloft/retrieve/route.ts +++ b/apps/sim/app/api/tools/agiloft/retrieve/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } 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' import { agiloftLogin, agiloftLogout, buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils' export const dynamic = 'force-dynamic' @@ -21,7 +22,7 @@ const AgiloftRetrieveSchema = z.object({ position: z.string().min(1, 'Position is required'), }) -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/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 4212cb2e547..fe88dfe8786 100644 --- a/apps/sim/app/api/tools/asana/create-task/route.ts +++ b/apps/sim/app/api/tools/asana/create-task/route.ts @@ -3,12 +3,13 @@ import { toError } from '@sim/utils/errors' 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) { @@ -128,4 +129,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 5d2b8a15b10..2649b77ba96 100644 --- a/apps/sim/app/api/tools/asana/update-task/route.ts +++ b/apps/sim/app/api/tools/asana/update-task/route.ts @@ -3,12 +3,13 @@ import { toError } from '@sim/utils/errors' 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) { @@ -127,4 +128,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/athena/create-named-query/route.ts b/apps/sim/app/api/tools/athena/create-named-query/route.ts index f49bc16247d..3d7c090a86d 100644 --- a/apps/sim/app/api/tools/athena/create-named-query/route.ts +++ b/apps/sim/app/api/tools/athena/create-named-query/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaCreateNamedQuery') @@ -18,7 +19,7 @@ const CreateNamedQuerySchema = z.object({ workGroup: 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) { @@ -66,4 +67,4 @@ export async function POST(request: NextRequest) { logger.error('CreateNamedQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/get-named-query/route.ts b/apps/sim/app/api/tools/athena/get-named-query/route.ts index 394bbda2b87..1cd6d99aa89 100644 --- a/apps/sim/app/api/tools/athena/get-named-query/route.ts +++ b/apps/sim/app/api/tools/athena/get-named-query/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaGetNamedQuery') @@ -14,7 +15,7 @@ const GetNamedQuerySchema = z.object({ namedQueryId: z.string().min(1, 'Named query 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) { @@ -63,4 +64,4 @@ export async function POST(request: NextRequest) { logger.error('GetNamedQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/get-query-execution/route.ts b/apps/sim/app/api/tools/athena/get-query-execution/route.ts index 129f4794dc4..362e20e86e4 100644 --- a/apps/sim/app/api/tools/athena/get-query-execution/route.ts +++ b/apps/sim/app/api/tools/athena/get-query-execution/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaGetQueryExecution') @@ -14,7 +15,7 @@ const GetQueryExecutionSchema = z.object({ queryExecutionId: z.string().min(1, 'Query execution 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) { @@ -74,4 +75,4 @@ export async function POST(request: NextRequest) { logger.error('GetQueryExecution failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/get-query-results/route.ts b/apps/sim/app/api/tools/athena/get-query-results/route.ts index 260cc73dc0c..3a35b52071d 100644 --- a/apps/sim/app/api/tools/athena/get-query-results/route.ts +++ b/apps/sim/app/api/tools/athena/get-query-results/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaGetQueryResults') @@ -19,7 +20,7 @@ const GetQueryResultsSchema = z.object({ nextToken: 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) { @@ -85,4 +86,4 @@ export async function POST(request: NextRequest) { logger.error('GetQueryResults failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/list-named-queries/route.ts b/apps/sim/app/api/tools/athena/list-named-queries/route.ts index 326e03711bb..6209890c23f 100644 --- a/apps/sim/app/api/tools/athena/list-named-queries/route.ts +++ b/apps/sim/app/api/tools/athena/list-named-queries/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaListNamedQueries') @@ -19,7 +20,7 @@ const ListNamedQueriesSchema = z.object({ nextToken: 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) { @@ -62,4 +63,4 @@ export async function POST(request: NextRequest) { logger.error('ListNamedQueries failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/list-query-executions/route.ts b/apps/sim/app/api/tools/athena/list-query-executions/route.ts index 958d09b1735..f8fa197e8af 100644 --- a/apps/sim/app/api/tools/athena/list-query-executions/route.ts +++ b/apps/sim/app/api/tools/athena/list-query-executions/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaListQueryExecutions') @@ -19,7 +20,7 @@ const ListQueryExecutionsSchema = z.object({ nextToken: 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) { @@ -62,4 +63,4 @@ export async function POST(request: NextRequest) { logger.error('ListQueryExecutions failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/start-query/route.ts b/apps/sim/app/api/tools/athena/start-query/route.ts index 0555246aa8e..bdbaa318a1e 100644 --- a/apps/sim/app/api/tools/athena/start-query/route.ts +++ b/apps/sim/app/api/tools/athena/start-query/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaStartQuery') @@ -18,7 +19,7 @@ const StartQuerySchema = z.object({ workGroup: 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) { @@ -77,4 +78,4 @@ export async function POST(request: NextRequest) { logger.error('StartQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/athena/stop-query/route.ts b/apps/sim/app/api/tools/athena/stop-query/route.ts index 0a7558d314a..a9f0b7d1ec2 100644 --- a/apps/sim/app/api/tools/athena/stop-query/route.ts +++ b/apps/sim/app/api/tools/athena/stop-query/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 { createAthenaClient } from '@/app/api/tools/athena/utils' const logger = createLogger('AthenaStopQuery') @@ -14,7 +15,7 @@ const StopQuerySchema = z.object({ queryExecutionId: z.string().min(1, 'Query execution 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) { @@ -53,4 +54,4 @@ export async function POST(request: NextRequest) { logger.error('StopQuery failed', { error: errorMessage }) return NextResponse.json({ error: errorMessage }, { 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 b4983ee6619..00016cccd8a 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) { @@ -101,4 +102,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 55d333a6d49..33ece1c084f 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/put-metric-data/route.ts b/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts index 8712b8200c8..28a6d490c6e 100644 --- a/apps/sim/app/api/tools/cloudwatch/put-metric-data/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/put-metric-data/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('CloudWatchPutMetricData') @@ -67,7 +68,7 @@ const PutMetricDataSchema = 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) { @@ -133,4 +134,4 @@ export async function POST(request: NextRequest) { logger.error('PutMetricData 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 f87b22c2243..9aaedef7686 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -74,4 +75,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 9935626eb99..788ea7dd8e8 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -106,4 +107,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 abb95ac321b..92aa7c8d712 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -42,7 +43,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) { @@ -137,12 +138,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) { @@ -289,12 +290,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) { @@ -393,12 +394,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) { @@ -458,4 +459,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 0da0754311f..bf1adac74d8 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -48,7 +49,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) { @@ -136,10 +137,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) { @@ -194,4 +195,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 9d9cb7706d9..7354ca7e6c8 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -91,10 +92,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) { @@ -197,4 +198,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 3b368a2ecd9..303897a0014 100644 --- a/apps/sim/app/api/tools/confluence/create-page/route.ts +++ b/apps/sim/app/api/tools/confluence/create-page/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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -9,7 +10,7 @@ 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) { @@ -124,4 +125,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 97fa0bf0740..b91a169ccf2 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -102,10 +103,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) { @@ -193,10 +194,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) { @@ -275,4 +276,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 a19c9409dbc..8373d603809 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -13,7 +14,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) { @@ -96,4 +97,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 9f6ce831d91..a4266b00aa1 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -13,7 +14,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) { @@ -104,4 +105,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 7a83448f118..8993dca11b7 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -17,7 +18,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) { @@ -114,4 +115,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 8faca8da5f9..0fc5fe25426 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -41,7 +42,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) { @@ -131,12 +132,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) { @@ -205,12 +206,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) { @@ -297,12 +298,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) { @@ -367,4 +368,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 6f328e534a7..006791ef53e 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -19,7 +20,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) { @@ -210,4 +211,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 b3d9ea72f32..8b4cbf35b83 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -76,7 +77,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) { @@ -149,9 +150,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) { @@ -264,9 +265,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) { @@ -326,4 +327,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 85d99b7ca09..26a4a938de0 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,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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -9,7 +10,7 @@ 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) { @@ -103,4 +104,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 949a27de427..1c970b596ae 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -108,4 +109,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 55638857d5b..b65607c1236 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -12,7 +13,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) { @@ -120,4 +121,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 901ffcc39bb..5b0bb8aed23 100644 --- a/apps/sim/app/api/tools/confluence/search/route.ts +++ b/apps/sim/app/api/tools/confluence/search/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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -9,7 +10,7 @@ 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) { @@ -105,4 +106,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 cd94c4a1ddb..42e539710ce 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -11,7 +12,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() @@ -95,4 +96,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 ffad0fd50a9..46907131535 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -13,7 +14,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) { @@ -123,4 +124,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 4af4dd9d016..5ba96d11161 100644 --- a/apps/sim/app/api/tools/confluence/space-labels/route.ts +++ b/apps/sim/app/api/tools/confluence/space-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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -9,7 +10,7 @@ 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) { @@ -98,4 +99,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 086ea56fa86..6dd488f982c 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -13,7 +14,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) { @@ -124,4 +125,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 7f8a652acd5..a10161d69cd 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -17,7 +18,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) { @@ -113,4 +114,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 588822de507..1030da77be8 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -18,7 +19,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) { @@ -210,4 +211,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 cdb0d316fa6..bbdd9c597fc 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -79,13 +80,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) { @@ -159,13 +160,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) { @@ -274,13 +275,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) { @@ -347,4 +348,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 066b0812044..2f8517ef638 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -10,7 +11,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) { @@ -94,4 +95,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 c6b1dae4b07..4aade319d5d 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -18,7 +19,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) { @@ -290,4 +291,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 ab18f49e784..e8cf09c43d1 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' @@ -11,7 +12,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) { @@ -137,4 +138,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 306722e9bda..ec208d8b7cb 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' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -13,7 +14,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) { @@ -84,4 +85,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index 19ee157f3b1..ca4454e0f73 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 type { CrowdStrikeAggregateQuery, CrowdStrikeCloud, @@ -347,7 +348,7 @@ async function postCrowdStrikeJson( }) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) @@ -482,4 +483,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] CrowdStrike request failed`, { error: message }) return NextResponse.json({ success: false, error: message }, { 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 426da0273ce..5ef4bf04295 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 { @@ -212,10 +213,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') @@ -323,4 +324,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 56fdea89898..7111e8f0b73 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 { generateId } from '@sim/utils/id' 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 { 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 e2733e73abc..2d1044fc2e2 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, @@ -43,7 +44,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 { @@ -276,4 +277,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/iam/add-user-to-group/route.ts b/apps/sim/app/api/tools/iam/add-user-to-group/route.ts index 5f69ced16de..5959ad9ad91 100644 --- a/apps/sim/app/api/tools/iam/add-user-to-group/route.ts +++ b/apps/sim/app/api/tools/iam/add-user-to-group/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { addUserToGroup, createIAMClient } from '../utils' const logger = createLogger('IAMAddUserToGroupAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ groupName: z.string().min(1, 'Group name 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) @@ -61,4 +62,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/attach-role-policy/route.ts b/apps/sim/app/api/tools/iam/attach-role-policy/route.ts index 010b64faf53..34ccbfa833c 100644 --- a/apps/sim/app/api/tools/iam/attach-role-policy/route.ts +++ b/apps/sim/app/api/tools/iam/attach-role-policy/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { attachRolePolicy, createIAMClient } from '../utils' const logger = createLogger('IAMAttachRolePolicyAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ policyArn: z.string().min(1, 'Policy ARN 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) @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/attach-user-policy/route.ts b/apps/sim/app/api/tools/iam/attach-user-policy/route.ts index 636d5e613d5..1f966951560 100644 --- a/apps/sim/app/api/tools/iam/attach-user-policy/route.ts +++ b/apps/sim/app/api/tools/iam/attach-user-policy/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { attachUserPolicy, createIAMClient } from '../utils' const logger = createLogger('IAMAttachUserPolicyAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ policyArn: z.string().min(1, 'Policy ARN 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) @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/create-access-key/route.ts b/apps/sim/app/api/tools/iam/create-access-key/route.ts index 6b247a12d78..7b08b343ae5 100644 --- a/apps/sim/app/api/tools/iam/create-access-key/route.ts +++ b/apps/sim/app/api/tools/iam/create-access-key/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createAccessKey, createIAMClient } from '../utils' const logger = createLogger('IAMCreateAccessKeyAPI') @@ -14,7 +15,7 @@ const Schema = z.object({ userName: z.string().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) @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/create-role/route.ts b/apps/sim/app/api/tools/iam/create-role/route.ts index a58f136ede9..2b035f4f743 100644 --- a/apps/sim/app/api/tools/iam/create-role/route.ts +++ b/apps/sim/app/api/tools/iam/create-role/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, createRole } from '../utils' const logger = createLogger('IAMCreateRoleAPI') @@ -18,7 +19,7 @@ const Schema = z.object({ maxSessionDuration: z.number().min(3600).max(43200).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) @@ -70,4 +71,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/create-user/route.ts b/apps/sim/app/api/tools/iam/create-user/route.ts index 941972be5c5..9b1df15b622 100644 --- a/apps/sim/app/api/tools/iam/create-user/route.ts +++ b/apps/sim/app/api/tools/iam/create-user/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, createUser } from '../utils' const logger = createLogger('IAMCreateUserAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ path: z.string().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) @@ -60,4 +61,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/delete-access-key/route.ts b/apps/sim/app/api/tools/iam/delete-access-key/route.ts index 50801563691..9b99953434e 100644 --- a/apps/sim/app/api/tools/iam/delete-access-key/route.ts +++ b/apps/sim/app/api/tools/iam/delete-access-key/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, deleteAccessKey } from '../utils' const logger = createLogger('IAMDeleteAccessKeyAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ userName: z.string().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) @@ -57,4 +58,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/delete-role/route.ts b/apps/sim/app/api/tools/iam/delete-role/route.ts index 4c0361868a1..13bacdedb2f 100644 --- a/apps/sim/app/api/tools/iam/delete-role/route.ts +++ b/apps/sim/app/api/tools/iam/delete-role/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, deleteRole } from '../utils' const logger = createLogger('IAMDeleteRoleAPI') @@ -14,7 +15,7 @@ const Schema = z.object({ roleName: z.string().min(1, 'Role name 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) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/delete-user/route.ts b/apps/sim/app/api/tools/iam/delete-user/route.ts index a9e484ace56..54e2402d4f0 100644 --- a/apps/sim/app/api/tools/iam/delete-user/route.ts +++ b/apps/sim/app/api/tools/iam/delete-user/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, deleteUser } from '../utils' const logger = createLogger('IAMDeleteUserAPI') @@ -14,7 +15,7 @@ const Schema = z.object({ userName: z.string().min(1, 'User name 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) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/detach-role-policy/route.ts b/apps/sim/app/api/tools/iam/detach-role-policy/route.ts index e7bd77c1811..e5b33f1f8da 100644 --- a/apps/sim/app/api/tools/iam/detach-role-policy/route.ts +++ b/apps/sim/app/api/tools/iam/detach-role-policy/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, detachRolePolicy } from '../utils' const logger = createLogger('IAMDetachRolePolicyAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ policyArn: z.string().min(1, 'Policy ARN 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) @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/detach-user-policy/route.ts b/apps/sim/app/api/tools/iam/detach-user-policy/route.ts index 5bbf0f6956b..05b4a03022c 100644 --- a/apps/sim/app/api/tools/iam/detach-user-policy/route.ts +++ b/apps/sim/app/api/tools/iam/detach-user-policy/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, detachUserPolicy } from '../utils' const logger = createLogger('IAMDetachUserPolicyAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ policyArn: z.string().min(1, 'Policy ARN 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) @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/get-role/route.ts b/apps/sim/app/api/tools/iam/get-role/route.ts index 3086f3f00fd..66496de54d6 100644 --- a/apps/sim/app/api/tools/iam/get-role/route.ts +++ b/apps/sim/app/api/tools/iam/get-role/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, getRole } from '../utils' const logger = createLogger('IAMGetRoleAPI') @@ -14,7 +15,7 @@ const Schema = z.object({ roleName: z.string().min(1, 'Role name 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) @@ -53,4 +54,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Failed to get IAM role:`, error) return NextResponse.json({ error: `Failed to get IAM role: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/iam/get-user/route.ts b/apps/sim/app/api/tools/iam/get-user/route.ts index 22964c75a26..7b8e4df5d5e 100644 --- a/apps/sim/app/api/tools/iam/get-user/route.ts +++ b/apps/sim/app/api/tools/iam/get-user/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, getUser } from '../utils' const logger = createLogger('IAMGetUserAPI') @@ -14,7 +15,7 @@ const Schema = z.object({ userName: z.string().min(1, 'User name 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) @@ -53,4 +54,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Failed to get IAM user:`, error) return NextResponse.json({ error: `Failed to get IAM user: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/iam/list-groups/route.ts b/apps/sim/app/api/tools/iam/list-groups/route.ts index 0d37da698c9..8c22793c48e 100644 --- a/apps/sim/app/api/tools/iam/list-groups/route.ts +++ b/apps/sim/app/api/tools/iam/list-groups/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, listGroups } from '../utils' const logger = createLogger('IAMListGroupsAPI') @@ -16,7 +17,7 @@ const Schema = z.object({ marker: z.string().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) @@ -58,4 +59,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/list-policies/route.ts b/apps/sim/app/api/tools/iam/list-policies/route.ts index 5e361bd66b8..76172b4ac23 100644 --- a/apps/sim/app/api/tools/iam/list-policies/route.ts +++ b/apps/sim/app/api/tools/iam/list-policies/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, listPolicies } from '../utils' const logger = createLogger('IAMListPoliciesAPI') @@ -18,7 +19,7 @@ const Schema = z.object({ marker: z.string().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) @@ -67,4 +68,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/list-roles/route.ts b/apps/sim/app/api/tools/iam/list-roles/route.ts index a1b9a5676f7..3cff739b12e 100644 --- a/apps/sim/app/api/tools/iam/list-roles/route.ts +++ b/apps/sim/app/api/tools/iam/list-roles/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, listRoles } from '../utils' const logger = createLogger('IAMListRolesAPI') @@ -16,7 +17,7 @@ const Schema = z.object({ marker: z.string().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) @@ -58,4 +59,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/list-users/route.ts b/apps/sim/app/api/tools/iam/list-users/route.ts index 13b99298574..521e3d11491 100644 --- a/apps/sim/app/api/tools/iam/list-users/route.ts +++ b/apps/sim/app/api/tools/iam/list-users/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, listUsers } from '../utils' const logger = createLogger('IAMListUsersAPI') @@ -16,7 +17,7 @@ const Schema = z.object({ marker: z.string().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) @@ -58,4 +59,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts b/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts index 5f3a537d3d0..854b7154df2 100644 --- a/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts +++ b/apps/sim/app/api/tools/iam/remove-user-from-group/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createIAMClient, removeUserFromGroup } from '../utils' const logger = createLogger('IAMRemoveUserFromGroupAPI') @@ -15,7 +16,7 @@ const Schema = z.object({ groupName: z.string().min(1, 'Group name 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) @@ -63,4 +64,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 c83ef413d09..b3c9cfe0eee 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/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' const logger = createLogger('ImageProxyAPI') @@ -14,7 +15,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() @@ -91,9 +92,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: { @@ -103,4 +104,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 2b5e39fa255..1c036e21d25 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 673123f9fe9..897719c0528 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' @@ -23,7 +24,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) { @@ -98,9 +99,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) { @@ -230,4 +231,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 a2b1b4aad42..8a933467f0d 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, parseAtlassianErrorMessage } 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) { @@ -98,9 +99,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) { @@ -176,4 +177,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 86986a7ccbc..7945b2d29bc 100644 --- a/apps/sim/app/api/tools/jira/update/route.ts +++ b/apps/sim/app/api/tools/jira/update/route.ts @@ -4,6 +4,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, parseAtlassianErrorMessage, toAdf } from '@/tools/jira/utils' export const dynamic = 'force-dynamic' @@ -30,7 +31,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) { @@ -195,4 +196,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 5cbd748fec7..be0bb063ef6 100644 --- a/apps/sim/app/api/tools/jira/write/route.ts +++ b/apps/sim/app/api/tools/jira/write/route.ts @@ -3,13 +3,14 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage, toAdf } 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) { @@ -238,4 +239,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 aab4fd51db4..ef9576f52da 100644 --- a/apps/sim/app/api/tools/jsm/approvals/route.ts +++ b/apps/sim/app/api/tools/jsm/approvals/route.ts @@ -8,6 +8,7 @@ import { validateJiraCloudId, validateJiraIssueKey, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -18,7 +19,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 }) @@ -212,4 +213,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 3a651d36504..4282f3655cc 100644 --- a/apps/sim/app/api/tools/jsm/comment/route.ts +++ b/apps/sim/app/api/tools/jsm/comment/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -125,4 +126,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 7ecfce61857..ad5f0a58bbd 100644 --- a/apps/sim/app/api/tools/jsm/comments/route.ts +++ b/apps/sim/app/api/tools/jsm/comments/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -118,4 +119,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 c60b2176ed5..5924cada69c 100644 --- a/apps/sim/app/api/tools/jsm/customers/route.ts +++ b/apps/sim/app/api/tools/jsm/customers/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -170,4 +171,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/answers/route.ts b/apps/sim/app/api/tools/jsm/forms/answers/route.ts index dbcb90d39b4..d37680801a9 100644 --- a/apps/sim/app/api/tools/jsm/forms/answers/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/answers/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmGetFormAnswersAPI') -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 }) @@ -109,4 +110,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/attach/route.ts b/apps/sim/app/api/tools/jsm/forms/attach/route.ts index 4cea4451ca8..a9399a68124 100644 --- a/apps/sim/app/api/tools/jsm/forms/attach/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/attach/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmAttachFormAPI') -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 }) @@ -117,4 +118,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/copy/route.ts b/apps/sim/app/api/tools/jsm/forms/copy/route.ts index 534b6d2e78c..c1644d3faae 100644 --- a/apps/sim/app/api/tools/jsm/forms/copy/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/copy/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmCopyFormsAPI') -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/forms/delete/route.ts b/apps/sim/app/api/tools/jsm/forms/delete/route.ts index 6849cfbff11..c5bab3e2868 100644 --- a/apps/sim/app/api/tools/jsm/forms/delete/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/delete/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmDeleteFormAPI') -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 }) @@ -109,4 +110,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts index a9f9ee9cb77..ccf23d9bb26 100644 --- a/apps/sim/app/api/tools/jsm/forms/externalise/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/externalise/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmExternaliseFormAPI') -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 }) @@ -110,4 +111,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/get/route.ts b/apps/sim/app/api/tools/jsm/forms/get/route.ts index 4582e626526..8517800edaa 100644 --- a/apps/sim/app/api/tools/jsm/forms/get/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/get/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmGetFormAPI') -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 }) @@ -111,4 +112,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts index 755686c36ea..56830c579d9 100644 --- a/apps/sim/app/api/tools/jsm/forms/internalise/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/internalise/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmInternaliseFormAPI') -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 }) @@ -110,4 +111,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/issue/route.ts b/apps/sim/app/api/tools/jsm/forms/issue/route.ts index 754d209ea39..6a21b5380d0 100644 --- a/apps/sim/app/api/tools/jsm/forms/issue/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/issue/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmIssueFormsAPI') -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 }) @@ -109,4 +110,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts index 7f734bc5f4e..912cee11f83 100644 --- a/apps/sim/app/api/tools/jsm/forms/reopen/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/reopen/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmReopenFormAPI') -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 }) @@ -110,4 +111,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/save/route.ts b/apps/sim/app/api/tools/jsm/forms/save/route.ts index 82a3a94403d..e5d7722e926 100644 --- a/apps/sim/app/api/tools/jsm/forms/save/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/save/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmSaveFormAnswersAPI') -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/forms/structure/route.ts b/apps/sim/app/api/tools/jsm/forms/structure/route.ts index 56497ef9511..5f07a0c04c4 100644 --- a/apps/sim/app/api/tools/jsm/forms/structure/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/structure/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmFormStructureAPI') -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 }) @@ -111,4 +112,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/submit/route.ts b/apps/sim/app/api/tools/jsm/forms/submit/route.ts index a54a8f0080b..5f2293cb02f 100644 --- a/apps/sim/app/api/tools/jsm/forms/submit/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/submit/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmSubmitFormAPI') -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 }) @@ -110,4 +111,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/jsm/forms/templates/route.ts b/apps/sim/app/api/tools/jsm/forms/templates/route.ts index 1021f5d0f6e..15bb4677334 100644 --- a/apps/sim/app/api/tools/jsm/forms/templates/route.ts +++ b/apps/sim/app/api/tools/jsm/forms/templates/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmFormsApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ export const dynamic = 'force-dynamic' const logger = createLogger('JsmFormTemplatesAPI') -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 }) @@ -109,4 +110,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 54e52091360..6fb3fe54f94 100644 --- a/apps/sim/app/api/tools/jsm/organization/route.ts +++ b/apps/sim/app/api/tools/jsm/organization/route.ts @@ -7,6 +7,7 @@ import { validateEnum, validateJiraCloudId, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -16,7 +17,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 }) @@ -182,4 +183,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 2677ecb840a..411160cb0ad 100644 --- a/apps/sim/app/api/tools/jsm/organizations/route.ts +++ b/apps/sim/app/api/tools/jsm/organizations/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -104,4 +105,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 004d3225e61..2b835d0de5d 100644 --- a/apps/sim/app/api/tools/jsm/participants/route.ts +++ b/apps/sim/app/api/tools/jsm/participants/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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -16,7 +17,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 }) @@ -187,4 +188,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 c700415a0c9..4f53dba106a 100644 --- a/apps/sim/app/api/tools/jsm/queues/route.ts +++ b/apps/sim/app/api/tools/jsm/queues/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -113,4 +114,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 bd28305b3ff..6874c8e5a84 100644 --- a/apps/sim/app/api/tools/jsm/request/route.ts +++ b/apps/sim/app/api/tools/jsm/request/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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -14,7 +15,7 @@ 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 }) @@ -263,4 +264,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 beff44d69ec..8e18855cdf7 100644 --- a/apps/sim/app/api/tools/jsm/requests/route.ts +++ b/apps/sim/app/api/tools/jsm/requests/route.ts @@ -7,6 +7,7 @@ import { validateEnum, validateJiraCloudId, } from '@/lib/core/security/input-validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -14,7 +15,7 @@ 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 }) @@ -153,4 +154,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 aba218ccfb0..23a09e55b39 100644 --- a/apps/sim/app/api/tools/jsm/requesttypefields/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypefields/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -121,4 +122,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 3f90b906e59..b79eb42329f 100644 --- a/apps/sim/app/api/tools/jsm/requesttypes/route.ts +++ b/apps/sim/app/api/tools/jsm/requesttypes/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -117,4 +118,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 5962aa672bb..0f20db9970c 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -11,7 +12,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() @@ -101,4 +102,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 c755bc11d79..3a6f8b2185b 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -11,7 +12,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() @@ -92,4 +93,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 209f06e9e8c..bddbbc3ccc6 100644 --- a/apps/sim/app/api/tools/jsm/servicedesks/route.ts +++ b/apps/sim/app/api/tools/jsm/servicedesks/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -95,4 +96,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 083034f5b14..9e8e22735d4 100644 --- a/apps/sim/app/api/tools/jsm/sla/route.ts +++ b/apps/sim/app/api/tools/jsm/sla/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -105,4 +106,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 75231994c71..3614367c99a 100644 --- a/apps/sim/app/api/tools/jsm/transition/route.ts +++ b/apps/sim/app/api/tools/jsm/transition/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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -14,7 +15,7 @@ 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 }) @@ -129,4 +130,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 c736eabc64d..67176399589 100644 --- a/apps/sim/app/api/tools/jsm/transitions/route.ts +++ b/apps/sim/app/api/tools/jsm/transitions/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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, parseAtlassianErrorMessage } from '@/tools/jira/utils' import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' @@ -10,7 +11,7 @@ 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 }) @@ -105,4 +106,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 f985ac00ea9..2adc8713ef2 100644 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts @@ -3,13 +3,14 @@ import { toError } from '@sim/utils/errors' 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() @@ -129,4 +130,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 718d55ca999..bd016d07bb5 100644 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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' @@ -122,7 +123,7 @@ const getChatDisplayName = async ( } } -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const body = await request.json() @@ -228,4 +229,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 787f3b9649f..9334ab937c4 100644 --- a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts +++ b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts @@ -3,13 +3,14 @@ import { toError } from '@sim/utils/errors' 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() @@ -119,4 +120,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts index d0dc8ef7c99..d00e8db13b4 100644 --- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts +++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validatePathSegment, 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' import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' @@ -22,7 +23,7 @@ interface GraphDrive { * Used by the microsoft.excel.drives selector to let users pick * which drive contains their Excel file. */ -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -132,4 +133,4 @@ export async function POST(request: NextRequest) { logger.error(`[${requestId}] Error fetching drives`, error) return NextResponse.json({ error: 'Internal server error' }, { 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 367e04fc413..fc8acb9bcea 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' import { getItemBasePath } from '@/tools/microsoft_excel/utils' @@ -23,7 +24,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`) @@ -117,4 +118,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/monday/boards/route.ts b/apps/sim/app/api/tools/monday/boards/route.ts index 938c9e15147..23d8ef71412 100644 --- a/apps/sim/app/api/tools/monday/boards/route.ts +++ b/apps/sim/app/api/tools/monday/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' export const dynamic = 'force-dynamic' const logger = createLogger('MondayBoardsAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() 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/monday/groups/route.ts b/apps/sim/app/api/tools/monday/groups/route.ts index 3fd973e0460..8fef3b2a809 100644 --- a/apps/sim/app/api/tools/monday/groups/route.ts +++ b/apps/sim/app/api/tools/monday/groups/route.ts @@ -3,13 +3,14 @@ import { NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { validateMondayNumericId } 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' const logger = createLogger('MondayGroupsAPI') -export async function POST(request: Request) { +export const POST = withRouteHandler(async (request: Request) => { try { const requestId = generateRequestId() const body = await request.json() @@ -90,4 +91,4 @@ export async function POST(request: Request) { { 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 6202e0e6e5c..db8b1ed6209 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 { generateId } from '@sim/utils/id' 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 { 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 54c1289af8f..64c4a73484e 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 { generateId } from '@sim/utils/id' 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 { 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 9461159d0ea..e987ad50af7 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 { generateId } from '@sim/utils/id' 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 { 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 c22ff8f0c8a..40cefb7aedb 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 { generateId } from '@sim/utils/id' 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 { 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 4ab2535fb73..5a365472968 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 { generateId } from '@sim/utils/id' 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 { 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 43eb7931b8f..c4038f5dc23 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 { generateId } from '@sim/utils/id' 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 { 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 b4125aa4334..4146bb49ba1 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 { generateId } from '@sim/utils/id' 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 { 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 4b235dd0700..0f20e8bed9e 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 { generateId } from '@sim/utils/id' 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 { 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 bcb1b869806..013e8cc650c 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 { generateId } from '@sim/utils/id' 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 { 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 fda8dc82b68..e22ecf5444d 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 { generateId } from '@sim/utils/id' 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 { 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 e6084340ea4..0950b2be1d1 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 { generateId } from '@sim/utils/id' 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 { 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 aa87d5c169b..bfcad56bbc8 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 { generateId } from '@sim/utils/id' 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 { 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 6a4717fde47..4ee7bbfd336 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 { generateId } from '@sim/utils/id' 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 { 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 a36e9218124..2338db5e843 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 { generateId } from '@sim/utils/id' 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 { 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 456da1f8b3f..9e74c736bc1 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 { generateId } from '@sim/utils/id' 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 { 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 beba37b8c7c..838d28377af 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 { generateId } from '@sim/utils/id' 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 { 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 20f3e64c936..1c8163876f7 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 { generateId } from '@sim/utils/id' 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 { 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 40f8e9d4c45..f578ffdfa11 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 { generateId } from '@sim/utils/id' 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 { 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 6ce96713f8a..8b910e887eb 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 { generateId } from '@sim/utils/id' 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 { 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 d2613a334e3..4dadb9d01d0 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 fd373641ec0..a499c06810f 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 { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +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 9a35dc108f6..d95353de1e3 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 21a17c5bb36..e9b5aedc995 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 { generateId } from '@sim/utils/id' 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 { 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 0507a6add39..bde63915323 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 { generateId } from '@sim/utils/id' 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 { 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 f0986e58937..aaf2276d8da 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 { generateId } from '@sim/utils/id' 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 { 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 5b01c9aa674..0a647a8c1f8 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 { generateId } from '@sim/utils/id' 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 { 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 1009be87435..63343675c4f 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 { generateId } from '@sim/utils/id' 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 { 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 d99c48db48f..60c9e71e922 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 { generateId } from '@sim/utils/id' 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 { 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 cac00a6f22a..d620b545f3e 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 { generateId } from '@sim/utils/id' 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 { 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 ea696f1f874..37c31ece818 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 { generateId } from '@sim/utils/id' 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 { 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 d85c3daefee..d9347865898 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 { generateId } from '@sim/utils/id' 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 { 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 ffb2833fb80..12ed5e067f2 100644 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ b/apps/sim/app/api/tools/outlook/folders/route.ts @@ -7,6 +7,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' @@ -20,7 +21,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) @@ -165,4 +166,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 8099febb5fa..ca15a1f0f81 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 { generateId } from '@sim/utils/id' 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 { 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 e898926280b..373c749be45 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 { generateId } from '@sim/utils/id' 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 { 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 7030bd622a2..447b098895c 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 { generateId } from '@sim/utils/id' 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 { 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 e7e476cefa2..462ae3f88fc 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 { generateId } from '@sim/utils/id' 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 { 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 f41d2b8598c..7726a8d5742 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 { generateId } from '@sim/utils/id' 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 { 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 e7241f0f980..2e0dcf4feb3 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 { generateId } from '@sim/utils/id' 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 { 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 318a0f3c9e8..a1921d5a186 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 { generateId } from '@sim/utils/id' 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 { 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 5dfdd9ebdc2..3e3dfdac2fe 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 { generateId } from '@sim/utils/id' 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 { 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 f80680db656..899a657937c 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 { generateId } from '@sim/utils/id' 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 { 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 e08ed73cf13..8f983033fac 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 { generateId } from '@sim/utils/id' 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 { 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 0793083c903..7fcc4bf8d30 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 { generateId } from '@sim/utils/id' 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 { 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 2dd8418ac7d..fcdcb67c94e 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 { generateId } from '@sim/utils/id' 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 { 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 ca56c9045ef..63d88ed0b93 100644 --- a/apps/sim/app/api/tools/search/route.ts +++ b/apps/sim/app/api/tools/search/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { SEARCH_TOOL_COST } from '@/lib/billing/constants' import { env } from '@/lib/core/config/env' +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 7f57a9c36b3..88f75174455 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 { generateId } from '@sim/utils/id' 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 { 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 82a7229a0ec..57efd4fc7db 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 { generateId } from '@sim/utils/id' 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 { 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 b8ca8a4cecb..ff88a00ed33 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 { generateId } from '@sim/utils/id' 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 { 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 dfe225589d5..3ed4b030f01 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 { generateId } from '@sim/utils/id' 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 { 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 6be86a34552..e7f1a8b7e1f 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 { generateId } from '@sim/utils/id' 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 { 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 7984e96d7d6..be3da5174bb 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 { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' +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 40f915367fe..3fe0753a913 100644 --- a/apps/sim/app/api/tools/smtp/send/route.ts +++ b/apps/sim/app/api/tools/smtp/send/route.ts @@ -6,6 +6,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' @@ -34,7 +35,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 { @@ -237,4 +238,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 e0f12c38334..634a7d097a3 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 { generateId } from '@sim/utils/id' 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 { 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 0e2d545f2df..148471b101c 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 { generateId } from '@sim/utils/id' 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 { 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 2bcce214702..969b3edd22d 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 NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper, Stats } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +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 5bca7dcf229..dc89f714ef4 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 { generateId } from '@sim/utils/id' 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 { 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 b0cd4374300..765bcaf28ec 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 { generateId } from '@sim/utils/id' 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 { 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 6aa443d6338..ac8bf86b986 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 NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +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 b888e298bee..c2852a84e4e 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 { generateId } from '@sim/utils/id' 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 { 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 4ba3e6f4f26..863df1a979e 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 { generateId } from '@sim/utils/id' 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 { 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 6594baa718b..fde26cd3f31 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 { generateId } from '@sim/utils/id' 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 { 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 d3f6895c574..caee5d6ad24 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 NextRequest, NextResponse } from 'next/server' import type { Client, FileEntry, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +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 1c2a7c96758..b6639479637 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 { generateId } from '@sim/utils/id' 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 { 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 5bd159e78a3..f88e374ccd6 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 NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +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 941c6db0874..a406b7f2ef3 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 NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +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 e670f9093a3..58500e76c77 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 NextRequest, NextResponse } from 'next/server' import type { Client, SFTPWrapper } from 'ssh2' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' +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/sts/assume-role/route.ts b/apps/sim/app/api/tools/sts/assume-role/route.ts index ebec8b8de99..177cf5ef06a 100644 --- a/apps/sim/app/api/tools/sts/assume-role/route.ts +++ b/apps/sim/app/api/tools/sts/assume-role/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { assumeRole, createSTSClient } from '../utils' const logger = createLogger('STSAssumeRoleAPI') @@ -19,7 +20,7 @@ const AssumeRoleSchema = z.object({ tokenCode: 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) @@ -70,4 +71,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Failed to assume role: ${errorMessage}` }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/tools/sts/get-access-key-info/route.ts b/apps/sim/app/api/tools/sts/get-access-key-info/route.ts index 536a5d7eb02..b161d95e23d 100644 --- a/apps/sim/app/api/tools/sts/get-access-key-info/route.ts +++ b/apps/sim/app/api/tools/sts/get-access-key-info/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createSTSClient, getAccessKeyInfo } from '../utils' const logger = createLogger('STSGetAccessKeyInfoAPI') @@ -14,7 +15,7 @@ const GetAccessKeyInfoSchema = z.object({ targetAccessKeyId: z.string().min(1, 'Target access key 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) @@ -60,4 +61,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/sts/get-caller-identity/route.ts b/apps/sim/app/api/tools/sts/get-caller-identity/route.ts index c625fb70615..e72c81bfbc3 100644 --- a/apps/sim/app/api/tools/sts/get-caller-identity/route.ts +++ b/apps/sim/app/api/tools/sts/get-caller-identity/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createSTSClient, getCallerIdentity } from '../utils' const logger = createLogger('STSGetCallerIdentityAPI') @@ -13,7 +14,7 @@ const GetCallerIdentitySchema = z.object({ secretAccessKey: z.string().min(1, 'AWS secret access key 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) @@ -59,4 +60,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} +}) diff --git a/apps/sim/app/api/tools/sts/get-session-token/route.ts b/apps/sim/app/api/tools/sts/get-session-token/route.ts index 338c102572f..33ea1b187d0 100644 --- a/apps/sim/app/api/tools/sts/get-session-token/route.ts +++ b/apps/sim/app/api/tools/sts/get-session-token/route.ts @@ -3,6 +3,7 @@ import { generateId } from '@sim/utils/id' 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 { createSTSClient, getSessionToken } from '../utils' const logger = createLogger('STSGetSessionTokenAPI') @@ -16,7 +17,7 @@ const GetSessionTokenSchema = z.object({ tokenCode: 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/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index 213bcf112cd..ae3f73fc361 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -9,6 +9,7 @@ import { secureFetchWithPinnedIP, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getMimeTypeFromExtension, isInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage, @@ -46,7 +47,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`) @@ -306,7 +307,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 a8795be41ca..ab374bc1962 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { validateSupabaseProjectId } from '@/lib/core/security/input-validation' 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' @@ -26,7 +27,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 { @@ -264,4 +265,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 f5ce04fce91..323b18568c5 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -11,6 +11,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 { @@ -308,7 +309,7 @@ async function pollForJobCompletion( ) } -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -653,4 +654,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 3b3851285a6..ca76382f1f3 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 { 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' const logger = createLogger('TrelloBoardsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { const apiKey = process.env.TRELLO_API_KEY @@ -107,4 +108,4 @@ export async function POST(request: NextRequest) { { 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 7f9db6e26e6..a6478e3894d 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 { NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' 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' 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 48c9865be32..2a91b29473a 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +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' @@ -13,7 +14,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`) @@ -271,7 +272,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 ac09226946d..bd6d6a0fed9 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 { type NextRequest, NextResponse } from 'next/server' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' +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 fd8aa9e3055..419cf096f10 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 { 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('UserSettingsAPI') @@ -45,7 +46,7 @@ const defaultSettings = { lastActiveWorkspaceId: null, } -export async function GET() { +export const GET = withRouteHandler(async () => { const requestId = generateRequestId() try { @@ -87,9 +88,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 { @@ -141,4 +142,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 46b798b493c..99e4095f875 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 @@ -11,6 +11,7 @@ import { ENTITLED_SUBSCRIPTION_STATUSES, hasPaidSubscriptionStatus, } from '@/lib/billing/subscriptions/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('SubscriptionTransferAPI') @@ -23,143 +24,145 @@ type TransferOutcome = | { kind: 'noop'; message: string } | { kind: 'success'; message: string } -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const subscriptionId = (await params).id - const session = await getSession() +export const POST = withRouteHandler( + async (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 }) - } + 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 } - ) - } + 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 validationResult = transferSubscriptionSchema.safeParse(body) + if (!validationResult.success) { + return NextResponse.json( + { + error: 'Invalid request parameters', + details: validationResult.error.format(), + }, + { status: 400 } + ) + } - const { organizationId } = validationResult.data - const userId = session.user.id - logger.info('Processing subscription transfer', { subscriptionId, organizationId }) + const { organizationId } = validationResult.data + const userId = session.user.id + logger.info('Processing subscription transfer', { subscriptionId, organizationId }) - const outcome = await db.transaction(async (tx): Promise => { - const [sub] = await tx - .select() - .from(subscription) - .where(eq(subscription.id, subscriptionId)) - .for('update') + const outcome = await db.transaction(async (tx): Promise => { + const [sub] = await tx + .select() + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + .for('update') - if (!sub) { - return { kind: 'error', status: 404, error: 'Subscription not found' } - } + if (!sub) { + return { kind: 'error', status: 404, error: 'Subscription not found' } + } - if (!isOrgPlan(sub.plan) || !hasPaidSubscriptionStatus(sub.status)) { - return { - kind: 'error', - status: 400, - error: - 'Only active Team or Enterprise subscriptions can be transferred to an organization.', + if (!isOrgPlan(sub.plan) || !hasPaidSubscriptionStatus(sub.status)) { + return { + kind: 'error', + status: 400, + error: + 'Only active Team or Enterprise subscriptions can be transferred to an organization.', + } } - } - const [org] = await tx - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .for('update') + const [org] = await tx + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .for('update') - if (!org) { - return { kind: 'error', status: 404, error: 'Organization not found' } - } + if (!org) { + return { kind: 'error', status: 404, error: 'Organization not found' } + } - const [mem] = await tx - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) - .limit(1) - - if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) { - return { - kind: 'error', - status: 403, - error: 'Unauthorized - user is not admin of organization', + const [mem] = await tx + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) + .limit(1) + + if (!mem || (mem.role !== 'owner' && mem.role !== 'admin')) { + return { + kind: 'error', + status: 403, + error: 'Unauthorized - user is not admin of organization', + } } - } - if (sub.referenceId === organizationId) { - return { kind: 'noop', message: 'Subscription already belongs to this organization' } - } + if (sub.referenceId === organizationId) { + return { kind: 'noop', message: 'Subscription already belongs to this organization' } + } - if (sub.referenceId !== userId) { - return { - kind: 'error', - status: 403, - error: 'Unauthorized - subscription does not belong to user', + if (sub.referenceId !== userId) { + return { + kind: 'error', + status: 403, + error: 'Unauthorized - subscription does not belong to user', + } } - } - const [existingOrgSub] = await tx - .select({ id: subscription.id }) - .from(subscription) - .where( - and( - eq(subscription.referenceId, organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + const [existingOrgSub] = await tx + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) ) - ) - .limit(1) - - if (existingOrgSub) { - return { - kind: 'error', - status: 409, - error: 'Organization already has an active subscription', + .limit(1) + + if (existingOrgSub) { + return { + kind: 'error', + status: 409, + error: 'Organization already has an active subscription', + } } - } - await tx - .update(subscription) - .set({ referenceId: organizationId }) - .where(eq(subscription.id, subscriptionId)) + await tx + .update(subscription) + .set({ referenceId: organizationId }) + .where(eq(subscription.id, subscriptionId)) - return { kind: 'success', message: 'Subscription transferred successfully' } - }) + return { kind: 'success', message: 'Subscription transferred successfully' } + }) - if (outcome.kind === 'error') { - return NextResponse.json({ error: outcome.error }, { status: outcome.status }) - } + if (outcome.kind === 'error') { + return NextResponse.json({ error: outcome.error }, { status: outcome.status }) + } - if (outcome.kind === 'success') { - logger.info('Subscription transfer completed', { - subscriptionId, - organizationId, - userId, + if (outcome.kind === 'success') { + logger.info('Subscription transfer completed', { + subscriptionId, + organizationId, + userId, + }) + } + + return NextResponse.json({ success: true, message: outcome.message }) + } catch (error) { + logger.error('Error transferring subscription', { + error: toError(error).message, }) + return NextResponse.json({ error: 'Failed to transfer subscription' }, { status: 500 }) } - - return NextResponse.json({ success: true, message: outcome.message }) - } catch (error) { - logger.error('Error transferring subscription', { - error: toError(error).message, - }) - 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 05d0d7cb650..e526f266863 100644 --- a/apps/sim/app/api/users/me/usage-logs/route.ts +++ b/apps/sim/app/api/users/me/usage-logs/route.ts @@ -5,6 +5,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') @@ -20,7 +21,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 }) @@ -120,4 +121,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 f97c755da33..cba6ba03a85 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 } from 'drizzle-orm' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -38,54 +39,56 @@ import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' 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 startDate = url.searchParams.get('startDate') || undefined - const endDate = url.searchParams.get('endDate') || undefined + const startDate = url.searchParams.get('startDate') || undefined + const endDate = url.searchParams.get('endDate') || undefined - if (startDate && Number.isNaN(Date.parse(startDate))) { - return badRequestResponse('Invalid startDate format. Use ISO 8601.') - } - if (endDate && Number.isNaN(Date.parse(endDate))) { - return badRequestResponse('Invalid endDate format. Use ISO 8601.') - } + if (startDate && Number.isNaN(Date.parse(startDate))) { + return badRequestResponse('Invalid startDate format. Use ISO 8601.') + } + if (endDate && Number.isNaN(Date.parse(endDate))) { + return badRequestResponse('Invalid endDate format. Use ISO 8601.') + } - try { - const conditions = buildFilterConditions({ - action: url.searchParams.get('action') || undefined, - resourceType: url.searchParams.get('resourceType') || undefined, - resourceId: url.searchParams.get('resourceId') || undefined, - workspaceId: url.searchParams.get('workspaceId') || undefined, - actorId: url.searchParams.get('actorId') || undefined, - actorEmail: url.searchParams.get('actorEmail') || undefined, - startDate, - endDate, - }) + try { + const conditions = buildFilterConditions({ + action: url.searchParams.get('action') || undefined, + resourceType: url.searchParams.get('resourceType') || undefined, + resourceId: url.searchParams.get('resourceId') || undefined, + workspaceId: url.searchParams.get('workspaceId') || undefined, + actorId: url.searchParams.get('actorId') || undefined, + actorEmail: url.searchParams.get('actorEmail') || undefined, + startDate, + endDate, + }) - 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 e4d393e8fb4..1fc0d5f658c 100644 --- a/apps/sim/app/api/v1/admin/credits/route.ts +++ b/apps/sim/app/api/v1/admin/credits/route.ts @@ -37,6 +37,7 @@ import { getEffectiveSeats, isOrgScopedSubscription, } from '@/lib/billing/subscriptions/utils' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -47,176 +48,178 @@ 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') + } - // Route admin credits to the subscription's entity (org if org-scoped). - if (isOrgScopedSubscription(userSubscription, resolvedUserId)) { - 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 + + // Route admin credits to the subscription's entity (org if org-scoped). + if (isOrgScopedSubscription(userSubscription, resolvedUserId)) { + 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 1630a5aeca5..8f531cd5eb7 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 @@ -34,6 +34,7 @@ import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { addUserToOrganization } 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, @@ -55,146 +56,165 @@ 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 === 'owner') { - return badRequestResponse( - 'Cannot change the owner role via this endpoint. Use POST /api/v1/admin/organizations/[id]/transfer-ownership instead.' - ) - } + if (existingMember) { + if (existingMember.organizationId === organizationId) { + if (existingMember.role === 'owner') { + return badRequestResponse( + 'Cannot change the owner role via this endpoint. Use POST /api/v1/admin/organizations/[id]/transfer-ownership instead.' + ) + } - if (existingMember.role !== body.role) { - await db.update(member).set({ role: body.role }).where(eq(member.id, existingMember.id)) + 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, @@ -202,64 +222,49 @@ 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') + } - 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 186163005b2..01b854cd470 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -27,6 +27,7 @@ import { validateOrganizationSlugOrThrow, } from '@/lib/billing/organizations/create-organization' 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, @@ -46,118 +47,122 @@ 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() + } + + if (body.slug !== undefined) { + if (typeof body.slug !== 'string' || body.slug.trim().length === 0) { + return badRequestResponse('slug must be a non-empty string') + } + const nextSlug = body.slug.trim() + validateOrganizationSlugOrThrow(nextSlug) + await ensureOrganizationSlugAvailable({ + slug: nextSlug, + excludeOrganizationId: organizationId, + }) + updateData.slug = nextSlug } - 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 (Object.keys(updateData).length === 1) { + return badRequestResponse( + 'No valid fields to update. Use /billing endpoint for orgUsageLimit.' + ) } - const nextSlug = body.slug.trim() - validateOrganizationSlugOrThrow(nextSlug) - await ensureOrganizationSlugAvailable({ - slug: nextSlug, - excludeOrganizationId: organizationId, + + 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'), }) - updateData.slug = nextSlug - } - if (Object.keys(updateData).length === 1) { - return badRequestResponse( - 'No valid fields to update. Use /billing endpoint for orgUsageLimit.' - ) - } + return singleResponse(toAdminOrganization(updated)) + } catch (error) { + if (error instanceof OrganizationSlugInvalidError) { + return badRequestResponse( + 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.' + ) + } - 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) { - if (error instanceof OrganizationSlugInvalidError) { - return badRequestResponse( - 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.' - ) - } + if (error instanceof OrganizationSlugTakenError) { + return badRequestResponse('This slug is already taken') + } - if (error instanceof OrganizationSlugTakenError) { - return badRequestResponse('This slug is already taken') + logger.error('Admin API: Failed to update organization', { error, organizationId }) + return internalErrorResponse('Failed to update organization') } - - 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/[id]/transfer-ownership/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts index 146564871a9..ab7dac3c813 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/transfer-ownership/route.ts @@ -3,6 +3,7 @@ import { member, organization, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { transferOrganizationOwnership } from '@/lib/billing/organizations/membership' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -17,107 +18,109 @@ interface RouteParams { id: string } -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().catch(() => null) - const newOwnerUserId: unknown = body?.newOwnerUserId - const currentOwnerUserIdOverride: unknown = body?.currentOwnerUserId + try { + const body = await request.json().catch(() => null) + const newOwnerUserId: unknown = body?.newOwnerUserId + const currentOwnerUserIdOverride: unknown = body?.currentOwnerUserId - if (typeof newOwnerUserId !== 'string' || newOwnerUserId.length === 0) { - return badRequestResponse('newOwnerUserId is required') - } + if (typeof newOwnerUserId !== 'string' || newOwnerUserId.length === 0) { + return badRequestResponse('newOwnerUserId is required') + } - if ( - currentOwnerUserIdOverride !== undefined && - (typeof currentOwnerUserIdOverride !== 'string' || currentOwnerUserIdOverride.length === 0) - ) { - return badRequestResponse('currentOwnerUserId must be a non-empty string when provided') - } + if ( + currentOwnerUserIdOverride !== undefined && + (typeof currentOwnerUserIdOverride !== 'string' || currentOwnerUserIdOverride.length === 0) + ) { + return badRequestResponse('currentOwnerUserId must be a non-empty string when provided') + } - const [orgRow] = await db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1) + const [orgRow] = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) - if (!orgRow) { - return notFoundResponse('Organization') - } + if (!orgRow) { + return notFoundResponse('Organization') + } - let currentOwnerUserId: string - if (typeof currentOwnerUserIdOverride === 'string') { - currentOwnerUserId = currentOwnerUserIdOverride - } else { - const [ownerMembership] = await db - .select({ userId: member.userId }) - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) - .limit(1) + let currentOwnerUserId: string + if (typeof currentOwnerUserIdOverride === 'string') { + currentOwnerUserId = currentOwnerUserIdOverride + } else { + const [ownerMembership] = await db + .select({ userId: member.userId }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner'))) + .limit(1) + + if (!ownerMembership) { + return badRequestResponse( + 'Organization has no owner; provide currentOwnerUserId explicitly to seed ownership' + ) + } + + currentOwnerUserId = ownerMembership.userId + } - if (!ownerMembership) { - return badRequestResponse( - 'Organization has no owner; provide currentOwnerUserId explicitly to seed ownership' - ) + if (currentOwnerUserId === newOwnerUserId) { + return badRequestResponse('New owner must differ from current owner') } - currentOwnerUserId = ownerMembership.userId - } + const [newOwnerMember] = 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, newOwnerUserId))) + .limit(1) - if (currentOwnerUserId === newOwnerUserId) { - return badRequestResponse('New owner must differ from current owner') - } + if (!newOwnerMember) { + return badRequestResponse('Target user is not a member of this organization') + } - const [newOwnerMember] = await db - .select({ - id: member.id, - role: member.role, - email: user.email, - name: user.name, + const result = await transferOrganizationOwnership({ + organizationId, + currentOwnerUserId, + newOwnerUserId, }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, newOwnerUserId))) - .limit(1) - if (!newOwnerMember) { - return badRequestResponse('Target user is not a member of this organization') - } + if (!result.success) { + return internalErrorResponse(result.error ?? 'Failed to transfer ownership') + } - const result = await transferOrganizationOwnership({ - organizationId, - currentOwnerUserId, - newOwnerUserId, - }) + logger.info(`Admin API: Transferred ownership of organization ${organizationId}`, { + currentOwnerUserId, + newOwnerUserId, + workspacesReassigned: result.workspacesReassigned, + billedAccountReassigned: result.billedAccountReassigned, + overageMigrated: result.overageMigrated, + billingBlockInherited: result.billingBlockInherited, + }) - if (!result.success) { - return internalErrorResponse(result.error ?? 'Failed to transfer ownership') + return singleResponse({ + organizationId, + currentOwnerUserId, + newOwnerUserId, + workspacesReassigned: result.workspacesReassigned, + billedAccountReassigned: result.billedAccountReassigned, + overageMigrated: result.overageMigrated, + billingBlockInherited: result.billingBlockInherited, + }) + } catch (error) { + logger.error('Admin API: Failed to transfer organization ownership', { + organizationId, + error, + }) + return internalErrorResponse('Failed to transfer ownership') } - - logger.info(`Admin API: Transferred ownership of organization ${organizationId}`, { - currentOwnerUserId, - newOwnerUserId, - workspacesReassigned: result.workspacesReassigned, - billedAccountReassigned: result.billedAccountReassigned, - overageMigrated: result.overageMigrated, - billingBlockInherited: result.billingBlockInherited, - }) - - return singleResponse({ - organizationId, - currentOwnerUserId, - newOwnerUserId, - workspacesReassigned: result.workspacesReassigned, - billedAccountReassigned: result.billedAccountReassigned, - overageMigrated: result.overageMigrated, - billingBlockInherited: result.billingBlockInherited, - }) - } catch (error) { - logger.error('Admin API: Failed to transfer organization ownership', { - organizationId, - error, - }) - return internalErrorResponse('Failed to transfer ownership') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/organizations/route.ts b/apps/sim/app/api/v1/admin/organizations/route.ts index 6960986aff9..64881d6330e 100644 --- a/apps/sim/app/api/v1/admin/organizations/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/route.ts @@ -30,6 +30,7 @@ import { OrganizationSlugInvalidError, OrganizationSlugTakenError, } from '@/lib/billing/organizations/create-organization' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -47,106 +48,110 @@ 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') } - - 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, memberId } = await createOrganizationWithOwner({ - ownerUserId: body.ownerId, - name, - slug, - }) - - 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) { - if (error instanceof OrganizationSlugInvalidError) { - return badRequestResponse( - 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.' - ) + }) +) + +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, memberId } = await createOrganizationWithOwner({ + ownerUserId: body.ownerId, + name, + slug, + }) + + 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) { + if (error instanceof OrganizationSlugInvalidError) { + return badRequestResponse( + 'Organization slug can only contain lowercase letters, numbers, hyphens, and underscores.' + ) + } + + if (error instanceof OrganizationSlugTakenError) { + return badRequestResponse('This slug is already taken') + } + + logger.error('Admin API: Failed to create organization', { error }) + return internalErrorResponse('Failed to create organization') } - - if (error instanceof OrganizationSlugTakenError) { - return badRequestResponse('This slug is already taken') - } - - logger.error('Admin API: Failed to create organization', { error }) - return internalErrorResponse('Failed to create organization') - } -}) + }) +) diff --git a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts index 0b1693bcdf8..782f00b831f 100644 --- a/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/[id]/requeue/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' const logger = createLogger('AdminOutboxRequeueAPI') @@ -19,45 +20,47 @@ export const dynamic = 'force-dynamic' * requeued — completed/pending/processing rows are rejected to avoid * operator errors. */ -export const POST = withAdminAuthParams<{ id: string }>(async (_request, { params }) => { - const { id } = await params +export const POST = + withRouteHandler(withAdminAuthParams < { id: string }) > + (async (_request, { params }) => { + const { id } = await params - try { - const result = await db - .update(outboxEvent) - .set({ - status: 'pending', - attempts: 0, - lastError: null, - availableAt: new Date(), - lockedAt: null, - processedAt: null, - }) - .where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter'))) - .returning({ id: outboxEvent.id, eventType: outboxEvent.eventType }) + try { + const result = await db + .update(outboxEvent) + .set({ + status: 'pending', + attempts: 0, + lastError: null, + availableAt: new Date(), + lockedAt: null, + processedAt: null, + }) + .where(and(eq(outboxEvent.id, id), eq(outboxEvent.status, 'dead_letter'))) + .returning({ id: outboxEvent.id, eventType: outboxEvent.eventType }) - if (result.length === 0) { - return NextResponse.json( - { - success: false, - error: - 'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.', - }, - { status: 404 } - ) - } + if (result.length === 0) { + return NextResponse.json( + { + success: false, + error: + 'Event not found or not in dead_letter status. Only dead-lettered events can be requeued.', + }, + { status: 404 } + ) + } - logger.info('Requeued dead-lettered outbox event', { - eventId: result[0].id, - eventType: result[0].eventType, - }) + logger.info('Requeued dead-lettered outbox event', { + eventId: result[0].id, + eventType: result[0].eventType, + }) - return NextResponse.json({ - success: true, - requeued: result[0], - }) - } catch (error) { - logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) - } -}) + return NextResponse.json({ + success: true, + requeued: result[0], + }) + } catch (error) { + logger.error('Failed to requeue outbox event', { eventId: id, error: toError(error).message }) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } + }) diff --git a/apps/sim/app/api/v1/admin/outbox/route.ts b/apps/sim/app/api/v1/admin/outbox/route.ts index 87acb5d006a..01d87d8b7ad 100644 --- a/apps/sim/app/api/v1/admin/outbox/route.ts +++ b/apps/sim/app/api/v1/admin/outbox/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, desc, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuth } from '@/app/api/v1/admin/middleware' const logger = createLogger('AdminOutboxAPI') @@ -25,60 +26,62 @@ export const dynamic = 'force-dynamic' * * Response includes aggregate counts by status for quick health read. */ -export const GET = withAdminAuth(async (request: NextRequest) => { - try { - const { searchParams } = new URL(request.url) - const validStatuses = ['pending', 'processing', 'completed', 'dead_letter'] as const - const status = (searchParams.get('status') ?? 'dead_letter') as (typeof validStatuses)[number] - if (!validStatuses.includes(status)) { - return NextResponse.json( - { - success: false, - error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, - }, - { status: 400 } - ) - } +export const GET = withRouteHandler( + withAdminAuth(async (request: NextRequest) => { + try { + const { searchParams } = new URL(request.url) + const validStatuses = ['pending', 'processing', 'completed', 'dead_letter'] as const + const status = (searchParams.get('status') ?? 'dead_letter') as (typeof validStatuses)[number] + if (!validStatuses.includes(status)) { + return NextResponse.json( + { + success: false, + error: `Invalid status. Must be one of: ${validStatuses.join(', ')}`, + }, + { status: 400 } + ) + } - const eventType = searchParams.get('eventType') + const eventType = searchParams.get('eventType') - const rawLimit = searchParams.get('limit') - const parsedLimit = rawLimit === null ? 100 : Number.parseInt(rawLimit, 10) - const limit = - Number.isFinite(parsedLimit) && parsedLimit > 0 - ? Math.min(500, Math.max(1, parsedLimit)) - : 100 + const rawLimit = searchParams.get('limit') + const parsedLimit = rawLimit === null ? 100 : Number.parseInt(rawLimit, 10) + const limit = + Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(500, Math.max(1, parsedLimit)) + : 100 - const whereConditions = [eq(outboxEvent.status, status)] - if (eventType) { - whereConditions.push(eq(outboxEvent.eventType, eventType)) - } + const whereConditions = [eq(outboxEvent.status, status)] + if (eventType) { + whereConditions.push(eq(outboxEvent.eventType, eventType)) + } - const rows = await db - .select() - .from(outboxEvent) - .where(and(...whereConditions)) - .orderBy(desc(outboxEvent.createdAt)) - .limit(limit) + const rows = await db + .select() + .from(outboxEvent) + .where(and(...whereConditions)) + .orderBy(desc(outboxEvent.createdAt)) + .limit(limit) - // Aggregate counts per (status, eventType) for at-a-glance health. - const counts = await db - .select({ - status: outboxEvent.status, - eventType: outboxEvent.eventType, - count: sql`count(*)::int`, - }) - .from(outboxEvent) - .groupBy(outboxEvent.status, outboxEvent.eventType) + // Aggregate counts per (status, eventType) for at-a-glance health. + const counts = await db + .select({ + status: outboxEvent.status, + eventType: outboxEvent.eventType, + count: sql`count(*)::int`, + }) + .from(outboxEvent) + .groupBy(outboxEvent.status, outboxEvent.eventType) - return NextResponse.json({ - success: true, - filter: { status, eventType, limit }, - rows, - counts, - }) - } catch (error) { - logger.error('Failed to list outbox events', { error: toError(error).message }) - return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) - } -}) + return NextResponse.json({ + success: true, + filter: { status, eventType, limit }, + rows, + counts, + }) + } catch (error) { + logger.error('Failed to list outbox events', { error: toError(error).message }) + return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 }) + } + }) +) 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 58d977c7707..f6549403c58 100644 --- a/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/subscriptions/[id]/route.ts @@ -30,6 +30,7 @@ import { eq } from 'drizzle-orm' import { requireStripeClient } from '@/lib/billing/stripe-client' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -45,118 +46,122 @@ 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) + try { + const [subData] = await db + .select() + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + .limit(1) - if (!subData) { - return notFoundResponse('Subscription') - } - - 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') } - - if (atPeriodEnd) { - await db.transaction(async (tx) => { - await tx - .update(subscription) - .set({ cancelAtPeriodEnd: true }) - .where(eq(subscription.id, subscriptionId)) - - await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { - stripeSubscriptionId: existing.stripeSubscriptionId, - subscriptionId: existing.id, - reason: reason ?? 'admin-cancel-at-period-end', + }) +) + +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') + } + + if (atPeriodEnd) { + await db.transaction(async (tx) => { + await tx + .update(subscription) + .set({ cancelAtPeriodEnd: true }) + .where(eq(subscription.id, subscriptionId)) + + await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END, { + stripeSubscriptionId: existing.stripeSubscriptionId, + subscriptionId: existing.id, + reason: reason ?? 'admin-cancel-at-period-end', + }) }) - }) - logger.info( - 'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)', - { + logger.info( + 'Admin API: Scheduled subscription cancellation at period end (DB committed, Stripe queued)', + { + 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, - plan: existing.plan, - referenceId: existing.referenceId, - periodEnd: existing.periodEnd, - reason, - } + atPeriodEnd: true, + periodEnd: existing.periodEnd?.toISOString() ?? null, + }) + } + + // Immediate cancellation — stays synchronous. Stripe's + // `customer.subscription.deleted` webhook triggers full cleanup + // (overage bill, usage reset, Pro restore, org delete) via + // `handleSubscriptionDeleted`, so no outbox needed here. + const stripe = requireStripeClient() + await stripe.subscriptions.cancel( + existing.stripeSubscriptionId, + { prorate: true, invoice_now: true }, + { idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` } ) + 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 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 — stays synchronous. Stripe's - // `customer.subscription.deleted` webhook triggers full cleanup - // (overage bill, usage reset, Pro restore, org delete) via - // `handleSubscriptionDeleted`, so no outbox needed here. - const stripe = requireStripeClient() - await stripe.subscriptions.cancel( - existing.stripeSubscriptionId, - { prorate: true, invoice_now: true }, - { idempotencyKey: `admin-cancel:${existing.stripeSubscriptionId}` } - ) - - 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 537e5b70eab..98306f6d954 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 { generateShortId } from '@sim/utils/id' import { eq, or } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +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 isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId) - const userSubscription = await getHighestPrioritySubscription(userId) - const isOrgScopedMember = isOrgScopedSubscription(userSubscription, userId) + 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 (isOrgScopedMember && orgMembership) { + warnings.push( + 'User is on an org-scoped subscription. Individual limits are ignored in favor of organization limits.' + ) + } - if (body.currentUsageLimit !== undefined) { - if (isOrgScopedMember && orgMembership) { - warnings.push( - 'User is on an org-scoped subscription. Individual limits are 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 5332b8a6afb..6f13d1b5e8c 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 { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' +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 37e13941b72..6a2bae5bf2f 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 { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +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 c5af318a5cf..7d243e5bab6 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 { generateId } from '@sim/utils/id' import { and, count, eq } from 'drizzle-orm' +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 5124c669a81..2f55c888124 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 { generateId } from '@sim/utils/id' import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +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 e21f6347f40..2c31a0c18bb 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -23,6 +23,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +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 { @@ -59,7 +60,7 @@ const QueryParamsSchema = z.object({ cursor: z.string().optional(), }) -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateId().slice(0, 8) try { @@ -125,4 +126,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 79b09f4e9d3..0a5cf8adb7b 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 { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' +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({ * - If exactly one workflow is available, uses that workflow as context * - Otherwise requires workflowId or workflowName to disambiguate */ -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) { @@ -142,4 +143,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 b3d3db8ceb8..13dea76f5a7 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,73 +88,75 @@ 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`, + metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, + 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`, - metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, - 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 2781fe164a2..3beb70d273e 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 { @@ -194,4 +195,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 22d40d979f1..19b0a80cb2a 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,159 +26,163 @@ 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`, + metadata: { knowledgeBaseId }, + 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`, - metadata: { knowledgeBaseId }, - 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 6eb61e22614..4cf12b2db2d 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,189 +49,194 @@ 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`, - metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, - 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`, + metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, + 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 b6b5ed3c8ac..700ac9a8a85 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,133 +45,139 @@ 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`, - metadata: { updatedFields: Object.keys(updates) }, - 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`, + metadata: { updatedFields: Object.keys(updates) }, + 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 61741d3c59e..3f64ec3f7e1 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 @@ -120,4 +121,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 8af970e4be9..85f1b28388d 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 { generateId } from '@sim/utils/id' 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' @@ -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 fc809c09c5e..d51448826de 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +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 00b2420cbb8..12ce351a811 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 @@ -6,6 +6,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' @@ -30,7 +31,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 { @@ -101,10 +102,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 { @@ -194,10 +195,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 { @@ -254,4 +255,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 31d5c1b1608..d2a8f837cec 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts @@ -6,6 +6,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, @@ -182,369 +183,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 = toError(error).message + 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 = toError(error).message - 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 = toError(error).message + const errorMessage = toError(error).message + + 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 ) @@ -552,53 +587,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 = toError(error).message - const errorMessage = toError(error).message + 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 8671b61a104..2859e6af019 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 @@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors' 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' @@ -28,93 +29,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 = toError(error).message - - 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 = toError(error).message + + 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 d0c0ad3e64d..bd564708650 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 { @@ -258,4 +259,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 5574d07b0bd..b3b6437c713 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 { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +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 2691c4b2378..29be1a1b708 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id' import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +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 e146c939507..f24a213b378 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,289 +18,292 @@ 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) - const webhookData = webhooks[0] + if (webhooks.length === 0) { + logger.warn(`[${requestId}] Webhook not found: ${id}`) + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: webhookData.workflow.id, - userId, - action: 'read', - }) - const hasAccess = authorization.allowed + const webhookData = webhooks[0] - if (!hasAccess) { - logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: webhookData.workflow.id, + userId, + action: 'read', + }) + const hasAccess = authorization.allowed - 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 }) + 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 }) + } } -} +) -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 }) + 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 (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, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) + .limit(1) - 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 (webhooks.length === 0) { + logger.warn(`[${requestId}] Webhook not found: ${id}`) + return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) + } - 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(), + const webhookData = webhooks[0] + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: webhookData.workflow.id, + userId, + action: 'write', }) - .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 canModify = authorization.allowed + + 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 }) + } } -} +) // 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 ${foundWebhook.provider || 'generic'} webhook`, + metadata: { + provider: foundWebhook.provider || 'generic', + workflowId: webhookData.workflow.id, + webhookPath: foundWebhook.path || undefined, + blockId: foundWebhook.blockId || undefined, + credentialSetId: credentialSetId || undefined, + }, + 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 ${foundWebhook.provider || 'generic'} webhook`, - metadata: { - provider: foundWebhook.provider || 'generic', - workflowId: webhookData.workflow.id, - webhookPath: foundWebhook.path || undefined, - blockId: foundWebhook.blockId || undefined, - credentialSetId: credentialSetId || undefined, - }, - 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 997e0cc688f..a603078b00e 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -14,6 +14,7 @@ import { and, eq, gt, ne, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { Webhook } from 'svix' import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +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/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index caf2065768b..6a5f2b385ee 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -5,6 +5,7 @@ import { verifyCronAuth } from '@/lib/auth/internal' import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers' import { processOutboxEvents } from '@/lib/core/outbox/service' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('OutboxProcessorAPI') @@ -15,7 +16,7 @@ const handlers = { ...billingOutboxHandlers, } as const -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { @@ -40,4 +41,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 3934555a978..06e3837e49c 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 { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { pollProvider, VALID_POLLING_PROVIDERS } from '@/lib/webhooks/polling' const logger = createLogger('PollingAPI') @@ -13,63 +14,65 @@ const LOCK_TTL_SECONDS = 180 export const dynamic = 'force-dynamic' export const maxDuration = 180 -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ provider: string }> } -) { - const { provider } = await params - const requestId = generateShortId() - - try { - const authError = verifyCronAuth(request, `${provider} webhook polling`) - if (authError) return authError - - if (!VALID_POLLING_PROVIDERS.has(provider)) { - return NextResponse.json({ error: `Unknown polling provider: ${provider}` }, { status: 404 }) - } - - const LOCK_KEY = `${provider}-polling-lock` - let lockValue: string | undefined +export const GET = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ provider: string }> }) => { + const { provider } = await params + const requestId = generateShortId() try { - lockValue = requestId - const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) - if (!locked) { + const authError = verifyCronAuth(request, `${provider} webhook polling`) + if (authError) return authError + + if (!VALID_POLLING_PROVIDERS.has(provider)) { return NextResponse.json( - { - success: true, - message: 'Polling already in progress – skipped', - requestId, - status: 'skip', - }, - { status: 202 } + { error: `Unknown polling provider: ${provider}` }, + { status: 404 } ) } - const results = await pollProvider(provider) + const LOCK_KEY = `${provider}-polling-lock` + let lockValue: string | undefined + + try { + lockValue = requestId + const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS) + if (!locked) { + return NextResponse.json( + { + success: true, + message: 'Polling already in progress – skipped', + requestId, + status: 'skip', + }, + { status: 202 } + ) + } + + const results = await pollProvider(provider) - return NextResponse.json({ - success: true, - message: `${provider} polling completed`, - requestId, - status: 'completed', - ...results, - }) - } finally { - if (lockValue) { - await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + return NextResponse.json({ + success: true, + message: `${provider} polling completed`, + requestId, + status: 'completed', + ...results, + }) + } finally { + if (lockValue) { + await releaseLock(LOCK_KEY, lockValue).catch(() => {}) + } } + } catch (error) { + logger.error(`Error during ${provider} polling (${requestId}):`, error) + return NextResponse.json( + { + success: false, + message: `${provider} polling failed`, + error: error instanceof Error ? error.message : 'Unknown error', + requestId, + }, + { status: 500 } + ) } - } catch (error) { - logger.error(`Error during ${provider} polling (${requestId}):`, error) - return NextResponse.json( - { - success: false, - message: `${provider} polling failed`, - error: error instanceof Error ? error.message : 'Unknown error', - requestId, - }, - { status: 500 } - ) } -} +) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index b9823e24082..762f0a1c7fc 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/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 { generateRequestId } from '@/lib/core/utils/request' +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 @@ -719,4 +720,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 abb37e8960e..6a6b509155e 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 { checkWebhookPreprocessing, findAllWebhooksForPath, @@ -22,37 +23,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 }) + ) } +) - try { - return await handleWebhookPost(request, params) - } finally { - ticket.release() +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() + } } -} +) 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 ff2c348c58e..b023a0b2af9 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 @@ -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 { performRevertToVersion } from '@/lib/workflows/orchestration' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -10,51 +11,53 @@ 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 - - try { - const { - error, - session, - workflow: workflowRecord, - } = await validateWorkflowPermissions(id, requestId, 'admin') - if (error) { - return createErrorResponse(error.message, error.status) +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) + } + + const versionSelector = version === 'active' ? null : Number(version) + if (version !== 'active' && !Number.isFinite(versionSelector)) { + return createErrorResponse('Invalid version', 400) + } + + const result = await performRevertToVersion({ + workflowId: id, + version: version === 'active' ? 'active' : (versionSelector as number), + userId: session!.user.id, + workflow: (workflowRecord ?? {}) as Record, + request, + actorName: session!.user.name ?? undefined, + actorEmail: session!.user.email ?? undefined, + }) + + if (!result.success) { + return createErrorResponse( + result.error || 'Failed to revert', + result.errorCode === 'not_found' ? 404 : 500 + ) + } + + return createSuccessResponse({ + message: 'Reverted to deployment version', + lastSaved: result.lastSaved, + }) + } catch (error: any) { + logger.error('Error reverting to deployment version', error) + return createErrorResponse(error.message || 'Failed to revert', 500) } - - const versionSelector = version === 'active' ? null : Number(version) - if (version !== 'active' && !Number.isFinite(versionSelector)) { - return createErrorResponse('Invalid version', 400) - } - - const result = await performRevertToVersion({ - workflowId: id, - version: version === 'active' ? 'active' : (versionSelector as number), - userId: session!.user.id, - workflow: (workflowRecord ?? {}) as Record, - request, - actorName: session!.user.name ?? undefined, - actorEmail: session!.user.email ?? undefined, - }) - - if (!result.success) { - return createErrorResponse( - result.error || 'Failed to revert', - result.errorCode === 'not_found' ? 404 : 500 - ) - } - - return createSuccessResponse({ - message: 'Reverted to deployment version', - lastSaved: result.lastSaved, - }) - } 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 0af8a82bae0..48e7ce50454 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,110 +21,114 @@ 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 + } + + 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` - ) + 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, - newWorkflowId: result.id, - folderId: folderId || undefined, - }, - 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, + newWorkflowId: result.id, + folderId: folderId || undefined, + }, + 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 ccbccbc6a60..723ca296054 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 { import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildNextCallChain, parseCallChain, @@ -262,23 +263,25 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise }) { - 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 889cc353dd5..1e7496fe87e 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 @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' 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 { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' import { abortManualExecution } from '@/lib/execution/manual-cancellation' @@ -16,121 +17,123 @@ 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) - let pausedCancelled = false - try { - pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId) - } catch (error) { - logger.warn('Failed to cancel paused execution in database', { executionId, error }) - } + const cancellation = await markExecutionCancelled(executionId) + const locallyAborted = abortManualExecution(executionId) + let pausedCancelled = false + try { + pausedCancelled = await PauseResumeManager.cancelPausedExecution(executionId) + } catch (error) { + logger.warn('Failed to cancel paused execution in database', { executionId, error }) + } - 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 if (pausedCancelled) { - logger.info('Paused execution cancelled directly in database', { executionId }) - void setExecutionMeta(executionId, { status: 'cancelled', workflowId }).catch(() => {}) - const writer = createExecutionEventWriter(executionId) - void writer - .write({ - type: 'execution:cancelled', - timestamp: new Date().toISOString(), + 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 if (pausedCancelled) { + logger.info('Paused execution cancelled directly in database', { executionId }) + void setExecutionMeta(executionId, { status: 'cancelled', workflowId }).catch(() => {}) + const writer = createExecutionEventWriter(executionId) + void writer + .write({ + type: 'execution:cancelled', + timestamp: new Date().toISOString(), + executionId, + workflowId, + data: { duration: 0 }, + }) + .then(() => writer.close()) + .catch(() => {}) + } else { + logger.warn('Execution cancellation was not durably recorded', { executionId, - workflowId, - data: { duration: 0 }, + reason: cancellation.reason, }) - .then(() => writer.close()) - .catch(() => {}) - } else { - logger.warn('Execution cancellation was not durably recorded', { - executionId, - reason: cancellation.reason, - }) - } + } - if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { - try { - await db - .update(workflowExecutionLogs) - .set({ status: 'cancelled', endedAt: new Date() }) - .where( - and( - eq(workflowExecutionLogs.executionId, executionId), - eq(workflowExecutionLogs.status, 'running') + if ((cancellation.durablyRecorded || locallyAborted) && !pausedCancelled) { + try { + await db + .update(workflowExecutionLogs) + .set({ status: 'cancelled', endedAt: new Date() }) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.status, 'running') + ) ) - ) - } catch (dbError) { - logger.warn('Failed to update execution log status directly', { - executionId, - error: dbError, - }) + } catch (dbError) { + logger.warn('Failed to update execution log status directly', { + executionId, + error: dbError, + }) + } } - } - const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled + const success = cancellation.durablyRecorded || locallyAborted || pausedCancelled + + if (success) { + const workspaceId = workflowAuthorization.workflow?.workspaceId + captureServerEvent( + auth.userId, + 'workflow_execution_cancelled', + { workflow_id: workflowId, workspace_id: workspaceId ?? '' }, + workspaceId ? { groups: { workspace: workspaceId } } : undefined + ) + } - if (success) { - 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, + executionId, + redisAvailable: cancellation.reason !== 'redis_unavailable', + durablyRecorded: cancellation.durablyRecorded, + locallyAborted, + pausedCancelled, + 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 } ) } - - return NextResponse.json({ - success, - executionId, - redisAvailable: cancellation.reason !== 'redis_unavailable', - durablyRecorded: cancellation.durablyRecorded, - locallyAborted, - pausedCancelled, - 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 5c1d7ee7659..8cf17e17a48 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 @@ -4,6 +4,7 @@ import { sleep } from '@sim/utils/helpers' 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, @@ -24,149 +25,151 @@ 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: 'Run buffer not found or expired' }, { status: 404 }) - } - - if (meta.workflowId && meta.workflowId !== workflowId) { - return NextResponse.json({ error: 'Run 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: 'Run buffer not found or expired' }, { status: 404 }) + } + + if (meta.workflowId && meta.workflowId !== workflowId) { + return NextResponse.json({ error: 'Run 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 sleep(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 sleep(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: toError(error).message, - }) - 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: toError(error).message, + }) + 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 dead4bf36db..74d56940aa3 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 run 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 run 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: 'Run logs persisted successfully', }) } - return createSuccessResponse({ - message: 'Run 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 c0b4d3d535f..54b3db3a3c4 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,72 +11,74 @@ 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}"`, - metadata: { - workflowName: workflowData.name, - workspaceId: workflowData.workspaceId || undefined, - }, - 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}"`, + metadata: { + workflowName: workflowData.name, + workspaceId: workflowData.workspaceId || undefined, + }, + 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 05eb9e90483..ac76548f8be 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -9,6 +9,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { getSocketServerUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' import { loadWorkflowFromNormalizedTables, @@ -119,233 +120,243 @@ 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: toError(error).message, - }) - 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: toError(error).message, + }) + 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 notifyResponse = await fetch(`${getSocketServerUrl()}/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 notifyResponse = await fetch(`${getSocketServerUrl()}/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 064669c9b8f..9a069c8ed7f 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,148 +31,152 @@ 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, + variableNames: Object.values(variables).map((v) => v.name), + workflowName: workflowData.name ?? undefined, + }, + 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, - variableNames: Object.values(variables).map((v) => v.name), - workflowName: workflowData.name ?? undefined, - }, - 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 2a61e33623a..aa211fa9eb3 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -8,6 +8,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 { 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) { @@ -334,4 +335,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 3345888a6f7..65bf80f1c4b 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,173 +17,185 @@ 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: `Renamed workspace API key from "${existingKey[0].name}" to "${name}"`, + metadata: { + keyType: 'workspace', + previousName: existingKey[0].name, + newName: 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: `Renamed workspace API key from "${existingKey[0].name}" to "${name}"`, - metadata: { - keyType: 'workspace', - previousName: existingKey[0].name, - newName: 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: { + keyType: 'workspace', + keyName: deletedKey.name, + 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: { - keyType: 'workspace', - keyName: deletedKey.name, - 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 261b60c6d2f..451757ad949 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 { 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 { 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, keyType: 'workspace', source: source ?? 'settings' }, + 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, keyType: 'workspace', source: source ?? 'settings' }, - 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} workspace API key(s)`, - metadata: { keyIds: keys, deletedCount, keyType: 'workspace' }, - 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} workspace API key(s)`, + metadata: { keyIds: keys, deletedCount, keyType: 'workspace' }, + 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 95abedccca6..29af19b139d 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 { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' @@ -50,138 +51,198 @@ 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 body = await request.json() - const { providerId, apiKey } = UpsertKeySchema.parse(body) + const userId = session.user.id - const { encrypted } = await encryptSecret(apiKey) + 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 existingKey = await db - .select() - .from(workspaceBYOKKeys) - .where( - and( - eq(workspaceBYOKKeys.workspaceId, workspaceId), - eq(workspaceBYOKKeys.providerId, providerId) + const { encrypted } = await encryptSecret(apiKey) + + 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}`) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.BYOK_KEY_UPDATED, + resourceType: AuditResourceType.BYOK_KEY, + resourceId: existingKey[0].id, + resourceName: providerId, + description: `Updated BYOK key for ${providerId}`, + metadata: { providerId }, + request, + }) + + 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_UPDATED, + action: AuditAction.BYOK_KEY_CREATED, resourceType: AuditResourceType.BYOK_KEY, - resourceId: existingKey[0].id, + resourceId: newKey.id, resourceName: providerId, - description: `Updated BYOK key for ${providerId}`, + description: `Added BYOK key for ${providerId}`, metadata: { providerId }, request, }) @@ -189,143 +250,89 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ 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]/data-retention/route.ts b/apps/sim/app/api/workspaces/[id]/data-retention/route.ts index d2308f16a18..d8cbaeed3b7 100644 --- a/apps/sim/app/api/workspaces/[id]/data-retention/route.ts +++ b/apps/sim/app/api/workspaces/[id]/data-retention/route.ts @@ -10,6 +10,7 @@ import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprisePlan } from '@/lib/billing/core/subscription' import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' @@ -48,171 +49,175 @@ const updateRetentionSchema = z.object({ * Returns the workspace's data retention config including plan defaults and * whether the workspace is on an enterprise plan. */ -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 }) - } - - const { id: workspaceId } = await params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } - - const [ws] = await db - .select({ - logRetentionHours: workspace.logRetentionHours, - softDeleteRetentionHours: workspace.softDeleteRetentionHours, - taskCleanupHours: workspace.taskCleanupHours, - billedAccountUserId: workspace.billedAccountUserId, +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) + if (!permission) { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) + } + + const [ws] = await db + .select({ + logRetentionHours: workspace.logRetentionHours, + softDeleteRetentionHours: workspace.softDeleteRetentionHours, + taskCleanupHours: workspace.taskCleanupHours, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + + if (!ws) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const plan = await resolveWorkspacePlan(ws.billedAccountUserId) + const defaults = getPlanDefaults(plan) + const isEnterpriseWorkspace = plan === 'enterprise' + + return NextResponse.json({ + success: true, + data: { + plan, + isEnterprise: isEnterpriseWorkspace, + defaults, + configured: { + logRetentionHours: ws.logRetentionHours, + softDeleteRetentionHours: ws.softDeleteRetentionHours, + taskCleanupHours: ws.taskCleanupHours, + }, + effective: isEnterpriseWorkspace + ? { + logRetentionHours: ws.logRetentionHours, + softDeleteRetentionHours: ws.softDeleteRetentionHours, + taskCleanupHours: ws.taskCleanupHours, + } + : { + logRetentionHours: defaults.logRetentionHours, + softDeleteRetentionHours: defaults.softDeleteRetentionHours, + taskCleanupHours: defaults.taskCleanupHours, + }, + }, }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - if (!ws) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } catch (error) { + logger.error('Failed to get data retention settings', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } - - const plan = await resolveWorkspacePlan(ws.billedAccountUserId) - const defaults = getPlanDefaults(plan) - const isEnterpriseWorkspace = plan === 'enterprise' - - return NextResponse.json({ - success: true, - data: { - plan, - isEnterprise: isEnterpriseWorkspace, - defaults, - configured: { - logRetentionHours: ws.logRetentionHours, - softDeleteRetentionHours: ws.softDeleteRetentionHours, - taskCleanupHours: ws.taskCleanupHours, - }, - effective: isEnterpriseWorkspace - ? { - logRetentionHours: ws.logRetentionHours, - softDeleteRetentionHours: ws.softDeleteRetentionHours, - taskCleanupHours: ws.taskCleanupHours, - } - : { - logRetentionHours: defaults.logRetentionHours, - softDeleteRetentionHours: defaults.softDeleteRetentionHours, - taskCleanupHours: defaults.taskCleanupHours, - }, - }, - }) - } catch (error) { - logger.error('Failed to get data retention settings', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } -} +) /** * PUT /api/workspaces/[id]/data-retention * Updates the workspace's data retention settings. * Requires admin permission and enterprise plan. */ -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: workspaceId } = await params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) - if (!billedAccountUserId) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const hasEnterprise = await isEnterprisePlan(billedAccountUserId) - if (!hasEnterprise) { - return NextResponse.json( - { error: 'Data Retention configuration is available on Enterprise plans only' }, - { status: 403 } - ) - } - - const body = await request.json() - const parsed = updateRetentionSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, - { status: 400 } - ) - } - - const updateData: Record = { updatedAt: new Date() } - - if (parsed.data.logRetentionHours !== undefined) { - updateData.logRetentionHours = parsed.data.logRetentionHours - } - if (parsed.data.softDeleteRetentionHours !== undefined) { - updateData.softDeleteRetentionHours = parsed.data.softDeleteRetentionHours - } - if (parsed.data.taskCleanupHours !== undefined) { - updateData.taskCleanupHours = parsed.data.taskCleanupHours - } - - const [updated] = await db - .update(workspace) - .set(updateData) - .where(eq(workspace.id, workspaceId)) - .returning({ - logRetentionHours: workspace.logRetentionHours, - softDeleteRetentionHours: workspace.softDeleteRetentionHours, - taskCleanupHours: workspace.taskCleanupHours, +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 { id: workspaceId } = await params + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId) + if (!billedAccountUserId) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const hasEnterprise = await isEnterprisePlan(billedAccountUserId) + if (!hasEnterprise) { + return NextResponse.json( + { error: 'Data Retention configuration is available on Enterprise plans only' }, + { status: 403 } + ) + } + + const body = await request.json() + const parsed = updateRetentionSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.errors[0]?.message ?? 'Invalid request body' }, + { status: 400 } + ) + } + + const updateData: Record = { updatedAt: new Date() } + + if (parsed.data.logRetentionHours !== undefined) { + updateData.logRetentionHours = parsed.data.logRetentionHours + } + if (parsed.data.softDeleteRetentionHours !== undefined) { + updateData.softDeleteRetentionHours = parsed.data.softDeleteRetentionHours + } + if (parsed.data.taskCleanupHours !== undefined) { + updateData.taskCleanupHours = parsed.data.taskCleanupHours + } + + const [updated] = await db + .update(workspace) + .set(updateData) + .where(eq(workspace.id, workspaceId)) + .returning({ + logRetentionHours: workspace.logRetentionHours, + softDeleteRetentionHours: workspace.softDeleteRetentionHours, + taskCleanupHours: workspace.taskCleanupHours, + }) + + if (!updated) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: 'Updated data retention settings', + metadata: { changes: parsed.data }, + request, }) - if (!updated) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.ORGANIZATION_UPDATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: 'Updated data retention settings', - metadata: { changes: parsed.data }, - request, - }) - - const defaults = getPlanDefaults('enterprise') - - return NextResponse.json({ - success: true, - data: { - plan: 'enterprise' as const, - isEnterprise: true, - defaults, - configured: { - logRetentionHours: updated.logRetentionHours, - softDeleteRetentionHours: updated.softDeleteRetentionHours, - taskCleanupHours: updated.taskCleanupHours, + const defaults = getPlanDefaults('enterprise') + + return NextResponse.json({ + success: true, + data: { + plan: 'enterprise' as const, + isEnterprise: true, + defaults, + configured: { + logRetentionHours: updated.logRetentionHours, + softDeleteRetentionHours: updated.softDeleteRetentionHours, + taskCleanupHours: updated.taskCleanupHours, + }, + effective: { + logRetentionHours: updated.logRetentionHours, + softDeleteRetentionHours: updated.softDeleteRetentionHours, + taskCleanupHours: updated.taskCleanupHours, + }, }, - effective: { - logRetentionHours: updated.logRetentionHours, - softDeleteRetentionHours: updated.softDeleteRetentionHours, - taskCleanupHours: updated.taskCleanupHours, - }, - }, - }) - } catch (error) { - logger.error('Failed to update data retention settings', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }) + } catch (error) { + logger.error('Failed to update data retention settings', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } } -} +) diff --git a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts index de5c1587583..c907ae337be 100644 --- a/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/docx/preview/route.ts @@ -1,3 +1,4 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' export const dynamic = 'force-dynamic' @@ -7,8 +8,10 @@ export const runtime = 'nodejs' * POST /api/workspaces/[id]/docx/preview * Compile docx source code and return the binary DOCX for streaming preview. */ -export const POST = createDocumentPreviewRoute({ - taskId: 'docx-generate', - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - label: 'DOCX', -}) +export const POST = withRouteHandler( + createDocumentPreviewRoute({ + taskId: 'docx-generate', + contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + label: 'DOCX', + }) +) 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 660062a2fd6..affbf182946 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 { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceEnvCredentials, deleteWorkspaceEnvCredentials, @@ -26,219 +27,222 @@ 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 }) + { 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 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 { 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 } + 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 }) + } - // Upsert by unique workspace_id - await db - .insert(workspaceEnvironment) - .values({ - id: generateId(), + 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() }, + }) + + const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted)) + await createWorkspaceEnvCredentials({ workspaceId, newKeys, actingUserId: userId }) + + 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 ${Object.keys(variables).length} workspace environment variable(s)`, + metadata: { + variableCount: Object.keys(variables).length, + updatedKeys: Object.keys(variables), + totalKeysAfterUpdate: Object.keys(merged).length, + }, + request, }) - const newKeys = Object.keys(variables).filter((k) => !(k in existingEncrypted)) - await createWorkspaceEnvCredentials({ workspaceId, newKeys, 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 ${Object.keys(variables).length} workspace environment variable(s)`, - metadata: { - variableCount: Object.keys(variables).length, - updatedKeys: Object.keys(variables), - totalKeysAfterUpdate: Object.keys(merged).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 deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys }) + + recordAudit({ workspaceId, - variables: current, - createdAt: wsRows[0]?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.ENVIRONMENT_DELETED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: workspaceId, + description: `Removed ${keys.length} workspace environment variable(s)`, + metadata: { + removedKeys: keys, + remainingKeysCount: Object.keys(current).length, + }, + request, }) - await deleteWorkspaceEnvCredentials({ workspaceId, removedKeys: keys }) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: session?.user?.name, - actorEmail: session?.user?.email, - action: AuditAction.ENVIRONMENT_DELETED, - resourceType: AuditResourceType.ENVIRONMENT, - resourceId: workspaceId, - description: `Removed ${keys.length} workspace environment variable(s)`, - metadata: { - removedKeys: keys, - remainingKeysCount: Object.keys(current).length, - }, - request, - }) - - 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 179efc41d3f..0897b99e193 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,83 +15,86 @@ 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, + resourceName: updatedFile.name, + description: `Updated content of file "${updatedFile.name}"`, + metadata: { contentSize: buffer.length }, + request, + }) - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Updated content of file "${updatedFile.name}"`, - metadata: { contentSize: buffer.length }, - 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 34cacc6808d..b692a15a2fc 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,120 +19,126 @@ 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, + resourceName: updatedFile.name, + 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, - resourceName: updatedFile.name, - 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 41bdf82569f..ca9bb9e18f3 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,141 +22,152 @@ 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}"`, + metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' }, + 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}"`, - metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' }, - 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 21d20c688db..41c87210e5f 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 { 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 { 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 ae5ae96c3e6..53a625e4af3 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) { @@ -298,9 +299,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) { @@ -370,4 +371,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 050e21bfccb..270522f2642 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 @@ -15,6 +15,7 @@ import { getSession } from '@/lib/auth' import { decryptSecret } from '@/lib/core/security/encryption' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -280,7 +281,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) { @@ -337,4 +338,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 14b9fe56f5d..ee8753fbdde 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 { 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_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants' @@ -116,205 +117,209 @@ 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`, - metadata: { - notificationType: data.notificationType, - allWorkflows: data.allWorkflows, - workflowCount: data.workflowIds.length, - levelFilter: data.levelFilter, - alertRule: data.alertConfig?.rule ?? null, - ...(data.notificationType === 'email' && { - recipientCount: data.emailRecipients?.length ?? 0, - }), - ...(data.notificationType === 'slack' && { channelName: data.slackConfig?.channelName }), - }, - 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`, + metadata: { + notificationType: data.notificationType, + allWorkflows: data.allWorkflows, + workflowCount: data.workflowIds.length, + levelFilter: data.levelFilter, + alertRule: data.alertConfig?.rule ?? null, + ...(data.notificationType === 'email' && { + recipientCount: data.emailRecipients?.length ?? 0, + }), + ...(data.notificationType === 'slack' && { channelName: data.slackConfig?.channelName }), + }, + 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]/pdf/preview/route.ts b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts index faaba5b8c42..71450a60c8b 100644 --- a/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pdf/preview/route.ts @@ -1,3 +1,4 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' export const dynamic = 'force-dynamic' @@ -7,8 +8,10 @@ export const runtime = 'nodejs' * POST /api/workspaces/[id]/pdf/preview * Compile PDF-Lib source code and return the binary PDF for streaming preview. */ -export const POST = createDocumentPreviewRoute({ - taskId: 'pdf-generate', - contentType: 'application/pdf', - label: 'PDF', -}) +export const POST = withRouteHandler( + createDocumentPreviewRoute({ + taskId: 'pdf-generate', + contentType: 'application/pdf', + label: 'PDF', + }) +) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index d5add813020..454dfcac68b 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 { 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 { 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,148 +84,150 @@ 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, + resourceName: permLookup.get(update.userId)?.email ?? update.userId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Changed permissions for ${permLookup.get(update.userId)?.email ?? update.userId} from ${permLookup.get(update.userId)?.permission ?? 'none'} to ${update.permissions}`, + metadata: { + targetUserId: update.userId, + targetEmail: permLookup.get(update.userId)?.email ?? undefined, + previousRole: permLookup.get(update.userId)?.permission ?? null, + newRole: update.permissions, + }, + request, + }) + } - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: permLookup.get(update.userId)?.email ?? update.userId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Changed permissions for ${permLookup.get(update.userId)?.email ?? update.userId} from ${permLookup.get(update.userId)?.permission ?? 'none'} to ${update.permissions}`, - metadata: { - targetUserId: update.userId, - targetEmail: permLookup.get(update.userId)?.email ?? undefined, - previousRole: permLookup.get(update.userId)?.permission ?? null, - newRole: 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 e189091f122..6c5bc642348 100644 --- a/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/pptx/preview/route.ts @@ -1,3 +1,4 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createDocumentPreviewRoute } from '@/app/api/workspaces/[id]/_preview/create-preview-route' export const dynamic = 'force-dynamic' @@ -7,8 +8,10 @@ export const runtime = 'nodejs' * POST /api/workspaces/[id]/pptx/preview * Compile PptxGenJS source code and return the binary PPTX for streaming preview. */ -export const POST = createDocumentPreviewRoute({ - taskId: 'pptx-generate', - contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - label: 'PPTX', -}) +export const POST = withRouteHandler( + createDocumentPreviewRoute({ + taskId: 'pptx-generate', + contentType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + label: 'PPTX', + }) +) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 662e3af48e4..b21753647ed 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({ @@ -35,352 +36,360 @@ 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 }) - } - - 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 }) - } - - // 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)) - - 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 (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - - // 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, - }, - }) -} + const workspaceId = id + const url = new URL(request.url) + const checkTemplates = url.searchParams.get('check-templates') === 'true' -export async function PATCH(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 + // 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 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 }) - } + // 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)) - try { - const body = patchWorkspaceSchema.parse(await request.json()) - const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body - - if ( - name === undefined && - color === undefined && - logoUrl === undefined && - billedAccountUserId === undefined && - allowPersonalApiKeys === undefined - ) { - return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) + 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 existingWorkspace = await db + // Get workspace details + const workspaceDetails = await db .select() .from(workspace) .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .then((rows) => rows[0]) - if (!existingWorkspace) { + if (!workspaceDetails) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } - const updateData: Record = {} + return NextResponse.json({ + workspace: { + ...workspaceDetails, + permissions: userPermission, + }, + }) + } +) - if (name !== undefined) { - updateData.name = name - } +export const PATCH = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - if (color !== undefined) { - updateData.color = color + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - if (logoUrl !== undefined) { - updateData.logoUrl = logoUrl - } + const workspaceId = id - if (allowPersonalApiKeys !== undefined) { - updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys) + // 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 }) } - if (billedAccountUserId !== undefined) { - if (existingWorkspace.organizationId && existingWorkspace.workspaceMode === 'organization') { - return NextResponse.json( - { - error: - 'Organization workspaces use organization billing and cannot change billed account.', - }, - { status: 400 } - ) + try { + const body = patchWorkspaceSchema.parse(await request.json()) + const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body + + if ( + name === undefined && + color === undefined && + logoUrl === undefined && + billedAccountUserId === undefined && + allowPersonalApiKeys === undefined + ) { + return NextResponse.json({ error: 'No updates provided' }, { status: 400 }) } - const candidateId = billedAccountUserId + const existingWorkspace = await db + .select() + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .then((rows) => rows[0]) - const isOwner = candidateId === existingWorkspace.ownerId + if (!existingWorkspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } - let hasAdminAccess = isOwner + const updateData: Record = {} - 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) + if (name !== undefined) { + updateData.name = name + } - hasAdminAccess = adminPermission.length > 0 + if (color !== undefined) { + updateData.color = color } - if (!hasAdminAccess) { - return NextResponse.json( - { error: 'Billed account must be a workspace admin' }, - { status: 400 } - ) + if (logoUrl !== undefined) { + updateData.logoUrl = logoUrl } - updateData.billedAccountUserId = candidateId - } + if (allowPersonalApiKeys !== undefined) { + updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys) + } - if (Object.keys(updateData).length === 0) { - return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 }) - } + if (billedAccountUserId !== undefined) { + if ( + existingWorkspace.organizationId && + existingWorkspace.workspaceMode === 'organization' + ) { + return NextResponse.json( + { + error: + 'Organization workspaces use organization billing and cannot change billed account.', + }, + { status: 400 } + ) + } - updateData.updatedAt = new Date() + const candidateId = billedAccountUserId - await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId)) + const isOwner = candidateId === existingWorkspace.ownerId - const updatedWorkspace = await db - .select() - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .then((rows) => rows[0]) + let hasAdminAccess = isOwner - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.WORKSPACE_UPDATED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: updatedWorkspace?.name ?? existingWorkspace.name, - description: `Updated workspace "${updatedWorkspace?.name ?? existingWorkspace.name}"`, - metadata: { - changes: { - ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), - ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }), - ...(logoUrl !== undefined && { - logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl }, - }), - ...(allowPersonalApiKeys !== undefined && { - allowPersonalApiKeys: { - from: existingWorkspace.allowPersonalApiKeys, - to: allowPersonalApiKeys, - }, - }), - ...(billedAccountUserId !== undefined && { - billedAccountUserId: { - from: existingWorkspace.billedAccountUserId, - to: billedAccountUserId, - }, - }), - }, - }, - request, - }) + 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) - 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 }) - } -} + hasAdminAccess = adminPermission.length > 0 + } -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const session = await getSession() + if (!hasAdminAccess) { + return NextResponse.json( + { error: 'Billed account must be a workspace admin' }, + { status: 400 } + ) + } - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + updateData.billedAccountUserId = candidateId + } + + if (Object.keys(updateData).length === 0) { + return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 }) + } - const workspaceId = id - const body = deleteWorkspaceSchema.parse(await request.json().catch(() => ({}))) - const { deleteTemplates } = body // User's choice: false = keep templates (recommended), true = delete templates + updateData.updatedAt = new Date() - // 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 }) - } + await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId)) - try { - const [[workspaceRecord], totalWorkspaces] = await Promise.all([ - db - .select({ name: workspace.name }) + const updatedWorkspace = await db + .select() .from(workspace) - .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) - .limit(1), - db - .select({ id: permissions.entityId }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where( - and( - eq(permissions.userId, session.user.id), - eq(permissions.entityType, 'workspace'), - isNull(workspace.archivedAt) - ) - ), - ]) + .where(eq(workspace.id, workspaceId)) + .then((rows) => rows[0]) + + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: updatedWorkspace?.name ?? existingWorkspace.name, + description: `Updated workspace "${updatedWorkspace?.name ?? existingWorkspace.name}"`, + metadata: { + changes: { + ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), + ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }), + ...(logoUrl !== undefined && { + logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl }, + }), + ...(allowPersonalApiKeys !== undefined && { + allowPersonalApiKeys: { + from: existingWorkspace.allowPersonalApiKeys, + to: allowPersonalApiKeys, + }, + }), + ...(billedAccountUserId !== undefined && { + billedAccountUserId: { + from: existingWorkspace.billedAccountUserId, + to: billedAccountUserId, + }, + }), + }, + }, + request, + }) - /** Counts all workspace memberships (any role), not just admin — prevents the user from reaching a zero-workspace state. */ - if (totalWorkspaces.length <= 1) { - return NextResponse.json({ error: 'Cannot delete the only workspace' }, { status: 400 }) + 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 }) } + } +) - logger.info( - `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` - ) +export const DELETE = withRouteHandler( + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const session = await getSession() - const workspaceWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const workflowIds = workspaceWorkflows.map((entry) => entry.id) + 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 (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)) - } + // 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 archiveResult = await archiveWorkspace(workspaceId, { - requestId: `workspace-${workspaceId}`, - }) + try { + const [[workspaceRecord], totalWorkspaces] = await Promise.all([ + db + .select({ name: workspace.name }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1), + db + .select({ id: permissions.entityId }) + .from(permissions) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where( + and( + eq(permissions.userId, session.user.id), + eq(permissions.entityType, 'workspace'), + isNull(workspace.archivedAt) + ) + ), + ]) - if (!archiveResult.archived && !workspaceRecord) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } + /** Counts all workspace memberships (any role), not just admin — prevents the user from reaching a zero-workspace state. */ + if (totalWorkspaces.length <= 1) { + return NextResponse.json({ error: 'Cannot delete the only workspace' }, { status: 400 }) + } + + 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)) + + 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}`, + }) + + 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/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 7eacb5fe9a3..1c01a23b9ad 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -8,6 +8,7 @@ import { getSession } from '@/lib/auth' import { getUserOrganization } from '@/lib/billing/organizations/membership' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' import { PlatformEvents } from '@/lib/core/telemetry' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listInvitationsForWorkspaces, normalizeEmail } from '@/lib/invitations/core' import { cancelPendingInvitation, @@ -29,7 +30,7 @@ const logger = createLogger('WorkspaceInvitationsAPI') type PermissionType = (typeof permissionTypeEnum.enumValues)[number] -export async function GET(req: NextRequest) { +export const GET = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -59,9 +60,9 @@ export async function GET(req: NextRequest) { logger.error('Error fetching workspace invitations:', error) return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) } -} +}) -export async function POST(req: NextRequest) { +export const POST = withRouteHandler(async (req: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -297,4 +298,4 @@ export async function POST(req: NextRequest) { logger.error('Error creating workspace invitation:', error) return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) } -} +}) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index e4a507c5a78..067112b74be 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,123 +17,125 @@ 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 member ${userId} from the workspace`, - metadata: { - removedUserId: userId, - removedUserRole: userPermission.permissionType, - 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 member ${userId} from the workspace`, + metadata: { + removedUserId: userId, + removedUserRole: userPermission.permissionType, + 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 ebdeecd9b51..a83f115e835 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' +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' @@ -35,7 +36,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) { @@ -168,10 +169,10 @@ export async function GET(request: Request) { lastActiveWorkspaceId, creationPolicy, }) -} +}) // 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) { @@ -243,7 +244,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, diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index ca6728b42aa..3b6c4cd5bfd 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -1,5 +1,5 @@ import { db, jobExecutionLogs, workflow, workflowSchedule } from '@sim/db' -import { createLogger } from '@sim/logger' +import { createLogger, runWithRequestContext } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { task } from '@trigger.dev/sdk' @@ -328,332 +328,337 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { const now = new Date(payload.now) const scheduledFor = payload.scheduledFor ? new Date(payload.scheduledFor) : null - logger.info(`[${requestId}] Starting schedule execution`, { - scheduleId: payload.scheduleId, - workflowId: payload.workflowId, - executionId, - }) + return runWithRequestContext({ requestId }, async () => { + logger.info(`[${requestId}] Starting schedule execution`, { + scheduleId: payload.scheduleId, + workflowId: payload.workflowId, + executionId, + }) - try { - const [scheduleRecord] = await db - .select({ - id: workflowSchedule.id, - workflowId: workflowSchedule.workflowId, - status: workflowSchedule.status, - archivedAt: workflowSchedule.archivedAt, - }) - .from(workflowSchedule) - .where(eq(workflowSchedule.id, payload.scheduleId)) - .limit(1) + try { + const [scheduleRecord] = await db + .select({ + id: workflowSchedule.id, + workflowId: workflowSchedule.workflowId, + status: workflowSchedule.status, + archivedAt: workflowSchedule.archivedAt, + }) + .from(workflowSchedule) + .where(eq(workflowSchedule.id, payload.scheduleId)) + .limit(1) + + if (!scheduleRecord) { + logger.info(`[${requestId}] Schedule no longer exists, skipping execution`, { + scheduleId: payload.scheduleId, + }) + return + } - if (!scheduleRecord) { - logger.info(`[${requestId}] Schedule no longer exists, skipping execution`, { - scheduleId: payload.scheduleId, - }) - return - } + if (scheduleRecord.archivedAt || scheduleRecord.status === 'disabled') { + logger.info(`[${requestId}] Schedule is archived or disabled, skipping execution`, { + scheduleId: payload.scheduleId, + }) + await releaseScheduleLock( + payload.scheduleId, + requestId, + now, + `Failed to release schedule ${payload.scheduleId} after archive/disabled check` + ) + return + } - if (scheduleRecord.archivedAt || scheduleRecord.status === 'disabled') { - logger.info(`[${requestId}] Schedule is archived or disabled, skipping execution`, { - scheduleId: payload.scheduleId, - }) - await releaseScheduleLock( - payload.scheduleId, - requestId, - now, - `Failed to release schedule ${payload.scheduleId} after archive/disabled check` + const loggingSession = new LoggingSession( + payload.workflowId, + executionId, + 'schedule', + requestId ) - return - } - const loggingSession = new LoggingSession( - payload.workflowId, - executionId, - 'schedule', - requestId - ) + const preprocessResult = await preprocessExecution({ + workflowId: payload.workflowId, + userId: 'unknown', // Will be resolved from workflow record + triggerType: 'schedule', + executionId, + requestId, + checkRateLimit: true, + checkDeployment: true, + loggingSession, + triggerData: { correlation }, + }) - const preprocessResult = await preprocessExecution({ - workflowId: payload.workflowId, - userId: 'unknown', // Will be resolved from workflow record - triggerType: 'schedule', - executionId, - requestId, - checkRateLimit: true, - checkDeployment: true, - loggingSession, - triggerData: { correlation }, - }) + if (!preprocessResult.success) { + const statusCode = preprocessResult.error?.statusCode || 500 - if (!preprocessResult.success) { - const statusCode = preprocessResult.error?.statusCode || 500 + switch (statusCode) { + case 401: { + logger.warn( + `[${requestId}] Authentication error during preprocessing, disabling schedule` + ) + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + lastFailedAt: now, + status: 'disabled', + }, + requestId, + `Failed to disable schedule ${payload.scheduleId} after authentication error` + ) + return + } - switch (statusCode) { - case 401: { - logger.warn( - `[${requestId}] Authentication error during preprocessing, disabling schedule` - ) - await applyScheduleUpdate( - payload.scheduleId, - { - updatedAt: now, - lastQueuedAt: null, - lastFailedAt: now, - status: 'disabled', - }, - requestId, - `Failed to disable schedule ${payload.scheduleId} after authentication error` - ) - return - } + case 403: { + logger.warn( + `[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessResult.error?.message}` + ) + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + lastFailedAt: now, + status: 'disabled', + }, + requestId, + `Failed to disable schedule ${payload.scheduleId} after authorization error` + ) + return + } - case 403: { - logger.warn( - `[${requestId}] Authorization error during preprocessing, disabling schedule: ${preprocessResult.error?.message}` - ) - await applyScheduleUpdate( - payload.scheduleId, - { - updatedAt: now, - lastQueuedAt: null, - lastFailedAt: now, - status: 'disabled', - }, - requestId, - `Failed to disable schedule ${payload.scheduleId} after authorization error` - ) - return - } + case 404: { + logger.warn(`[${requestId}] Workflow not found, disabling schedule`) + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + status: 'disabled', + }, + requestId, + `Failed to disable schedule ${payload.scheduleId} after missing workflow` + ) + return + } - case 404: { - logger.warn(`[${requestId}] Workflow not found, disabling schedule`) - await applyScheduleUpdate( - payload.scheduleId, - { - updatedAt: now, - lastQueuedAt: null, - status: 'disabled', - }, - requestId, - `Failed to disable schedule ${payload.scheduleId} after missing workflow` - ) - return - } + case 429: { + logger.warn(`[${requestId}] Rate limit exceeded, scheduling retry`) + const retryDelay = 5 * 60 * 1000 + const nextRetryAt = new Date(now.getTime() + retryDelay) + + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + nextRunAt: nextRetryAt, + lastQueuedAt: null, + }, + requestId, + `Error updating schedule ${payload.scheduleId} for rate limit` + ) + return + } - case 429: { - logger.warn(`[${requestId}] Rate limit exceeded, scheduling retry`) - const retryDelay = 5 * 60 * 1000 - const nextRetryAt = new Date(now.getTime() + retryDelay) + case 402: { + logger.warn(`[${requestId}] Usage limit exceeded, scheduling next run`) + const nextRunAt = + (await calculateNextRunFromDeployment(payload, requestId)) ?? + new Date(now.getTime() + 60 * 60 * 1000) + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + nextRunAt, + }, + requestId, + `Error updating schedule ${payload.scheduleId} after usage limit check` + ) + return + } - await applyScheduleUpdate( - payload.scheduleId, - { - updatedAt: now, - nextRunAt: nextRetryAt, - lastQueuedAt: null, - }, - requestId, - `Error updating schedule ${payload.scheduleId} for rate limit` - ) - return + default: { + logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) + const nextRunAt = await determineNextRunAfterError(payload, now, requestId) + const newFailedCount = (payload.failedCount || 0) + 1 + const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES + + if (shouldDisable) { + logger.warn( + `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` + ) + } + + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + nextRunAt, + failedCount: newFailedCount, + lastFailedAt: now, + status: shouldDisable ? 'disabled' : 'active', + }, + requestId, + `Error updating schedule ${payload.scheduleId} after preprocessing failure` + ) + return + } } + } - case 402: { - logger.warn(`[${requestId}] Usage limit exceeded, scheduling next run`) - const nextRunAt = - (await calculateNextRunFromDeployment(payload, requestId)) ?? - new Date(now.getTime() + 60 * 60 * 1000) + const { actorUserId, workflowRecord } = preprocessResult + if (!actorUserId || !workflowRecord) { + logger.error(`[${requestId}] Missing required preprocessing data`) + await releaseScheduleLock( + payload.scheduleId, + requestId, + now, + `Failed to release schedule ${payload.scheduleId} after missing preprocessing data` + ) + return + } + + if (!workflowRecord.workspaceId) { + throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) + } + + logger.info(`[${requestId}] Executing scheduled workflow ${payload.workflowId}`) + + try { + const executionResult = await runWorkflowExecution({ + payload, + correlation, + workflowRecord, + actorUserId, + loggingSession, + requestId, + executionId, + asyncTimeout: preprocessResult.executionTimeout?.async, + }) + + if (executionResult.status === 'skip') { await applyScheduleUpdate( payload.scheduleId, { updatedAt: now, lastQueuedAt: null, - nextRunAt, + lastFailedAt: now, + status: 'disabled', + nextRunAt: null, }, requestId, - `Error updating schedule ${payload.scheduleId} after usage limit check` + `Failed to disable schedule ${payload.scheduleId} after skip` ) return } - default: { - logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`) - const nextRunAt = await determineNextRunAfterError(payload, now, requestId) - const newFailedCount = (payload.failedCount || 0) + 1 - const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES + if (executionResult.status === 'success') { + logger.info(`[${requestId}] Workflow ${payload.workflowId} executed successfully`) - if (shouldDisable) { - logger.warn( - `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` - ) - } + const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) await applyScheduleUpdate( payload.scheduleId, { + lastRanAt: now, updatedAt: now, - lastQueuedAt: null, nextRunAt, - failedCount: newFailedCount, - lastFailedAt: now, - status: shouldDisable ? 'disabled' : 'active', + failedCount: 0, + lastQueuedAt: null, }, requestId, - `Error updating schedule ${payload.scheduleId} after preprocessing failure` + `Error updating schedule ${payload.scheduleId} after success` ) return } - } - } - - const { actorUserId, workflowRecord } = preprocessResult - if (!actorUserId || !workflowRecord) { - logger.error(`[${requestId}] Missing required preprocessing data`) - await releaseScheduleLock( - payload.scheduleId, - requestId, - now, - `Failed to release schedule ${payload.scheduleId} after missing preprocessing data` - ) - return - } - if (!workflowRecord.workspaceId) { - throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) - } + logger.warn(`[${requestId}] Workflow ${payload.workflowId} execution failed`) - logger.info(`[${requestId}] Executing scheduled workflow ${payload.workflowId}`) + const newFailedCount = (payload.failedCount || 0) + 1 + const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES + if (shouldDisable) { + logger.warn( + `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` + ) + } - try { - const executionResult = await runWorkflowExecution({ - payload, - correlation, - workflowRecord, - actorUserId, - loggingSession, - requestId, - executionId, - asyncTimeout: preprocessResult.executionTimeout?.async, - }) + const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) - if (executionResult.status === 'skip') { await applyScheduleUpdate( payload.scheduleId, { updatedAt: now, lastQueuedAt: null, + nextRunAt, + failedCount: newFailedCount, lastFailedAt: now, - status: 'disabled', - nextRunAt: null, + status: shouldDisable ? 'disabled' : 'active', }, requestId, - `Failed to disable schedule ${payload.scheduleId} after skip` + `Error updating schedule ${payload.scheduleId} after failure` ) - return - } + } catch (error: unknown) { + const errorMessage = toError(error).message - if (executionResult.status === 'success') { - logger.info(`[${requestId}] Workflow ${payload.workflowId} executed successfully`) - - const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) + if (errorMessage.includes('Service overloaded')) { + logger.warn(`[${requestId}] Service overloaded, retrying schedule in 5 minutes`) - await applyScheduleUpdate( - payload.scheduleId, - { - lastRanAt: now, - updatedAt: now, - nextRunAt, - failedCount: 0, - lastQueuedAt: null, - }, - requestId, - `Error updating schedule ${payload.scheduleId} after success` - ) - return - } + const retryDelay = 5 * 60 * 1000 + const nextRetryAt = new Date(now.getTime() + retryDelay) - logger.warn(`[${requestId}] Workflow ${payload.workflowId} execution failed`) + await applyScheduleUpdate( + payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + nextRunAt: nextRetryAt, + }, + requestId, + `Error updating schedule ${payload.scheduleId} for service overload` + ) + return + } - const newFailedCount = (payload.failedCount || 0) + 1 - const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES - if (shouldDisable) { - logger.warn( - `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` + logger.error( + `[${requestId}] Error executing scheduled workflow ${payload.workflowId}`, + error ) - } - - const nextRunAt = calculateNextRunTime(payload, executionResult.blocks) - - await applyScheduleUpdate( - payload.scheduleId, - { - updatedAt: now, - lastQueuedAt: null, - nextRunAt, - failedCount: newFailedCount, - lastFailedAt: now, - status: shouldDisable ? 'disabled' : 'active', - }, - requestId, - `Error updating schedule ${payload.scheduleId} after failure` - ) - } catch (error: unknown) { - const errorMessage = toError(error).message - if (errorMessage.includes('Service overloaded')) { - logger.warn(`[${requestId}] Service overloaded, retrying schedule in 5 minutes`) + const nextRunAt = await determineNextRunAfterError(payload, now, requestId) + const newFailedCount = (payload.failedCount || 0) + 1 + const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES - const retryDelay = 5 * 60 * 1000 - const nextRetryAt = new Date(now.getTime() + retryDelay) + if (shouldDisable) { + logger.warn( + `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` + ) + } await applyScheduleUpdate( payload.scheduleId, { updatedAt: now, lastQueuedAt: null, - nextRunAt: nextRetryAt, + nextRunAt, + failedCount: newFailedCount, + lastFailedAt: now, + status: shouldDisable ? 'disabled' : 'active', }, requestId, - `Error updating schedule ${payload.scheduleId} for service overload` - ) - return - } - - logger.error(`[${requestId}] Error executing scheduled workflow ${payload.workflowId}`, error) - - const nextRunAt = await determineNextRunAfterError(payload, now, requestId) - const newFailedCount = (payload.failedCount || 0) + 1 - const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES - - if (shouldDisable) { - logger.warn( - `[${requestId}] Disabling schedule for workflow ${payload.workflowId} after ${MAX_CONSECUTIVE_FAILURES} consecutive failures` + `Error updating schedule ${payload.scheduleId} after execution error` ) } - - await applyScheduleUpdate( + } catch (error: unknown) { + logger.error(`[${requestId}] Error processing schedule ${payload.scheduleId}`, error) + await releaseScheduleLock( payload.scheduleId, - { - updatedAt: now, - lastQueuedAt: null, - nextRunAt, - failedCount: newFailedCount, - lastFailedAt: now, - status: shouldDisable ? 'disabled' : 'active', - }, requestId, - `Error updating schedule ${payload.scheduleId} after execution error` + now, + `Failed to release schedule ${payload.scheduleId} after unhandled error` ) } - } catch (error: unknown) { - logger.error(`[${requestId}] Error processing schedule ${payload.scheduleId}`, error) - await releaseScheduleLock( - payload.scheduleId, - requestId, - now, - `Failed to release schedule ${payload.scheduleId} after unhandled error` - ) - } + }) } export type JobExecutionPayload = { diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 113e8153551..ed3e1fe9d83 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { account, webhook } from '@sim/db/schema' -import { createLogger } from '@sim/logger' +import { createLogger, runWithRequestContext } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { task } from '@trigger.dev/sdk' @@ -144,30 +144,32 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { const executionId = correlation.executionId const requestId = correlation.requestId - logger.info(`[${requestId}] Starting webhook execution`, { - webhookId: payload.webhookId, - workflowId: payload.workflowId, - provider: payload.provider, - userId: payload.userId, - executionId, - }) + return runWithRequestContext({ requestId }, async () => { + logger.info(`[${requestId}] Starting webhook execution`, { + webhookId: payload.webhookId, + workflowId: payload.workflowId, + provider: payload.provider, + userId: payload.userId, + executionId, + }) - const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey( - payload.webhookId, - payload.headers, - payload.body, - payload.provider - ) + const idempotencyKey = IdempotencyService.createWebhookIdempotencyKey( + payload.webhookId, + payload.headers, + payload.body, + payload.provider + ) - const runOperation = async () => { - return await executeWebhookJobInternal(payload, correlation) - } + const runOperation = async () => { + return await executeWebhookJobInternal(payload, correlation) + } - return await webhookIdempotency.executeWithIdempotency( - payload.provider, - idempotencyKey, - runOperation - ) + return await webhookIdempotency.executeWithIdempotency( + payload.provider, + idempotencyKey, + runOperation + ) + }) } export async function resolveWebhookExecutionProviderConfig< diff --git a/apps/sim/background/workflow-execution.ts b/apps/sim/background/workflow-execution.ts index 7718625f72e..06d4e34180b 100644 --- a/apps/sim/background/workflow-execution.ts +++ b/apps/sim/background/workflow-execution.ts @@ -1,4 +1,4 @@ -import { createLogger } from '@sim/logger' +import { createLogger, runWithRequestContext } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { task } from '@trigger.dev/sdk' @@ -59,141 +59,145 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { const executionId = correlation.executionId const requestId = correlation.requestId - logger.info(`[${requestId}] Starting workflow execution job: ${workflowId}`, { - userId: payload.userId, - triggerType: payload.triggerType, - executionId, - }) - - const triggerType = (correlation.triggerType || 'api') as CoreTriggerType - const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId) - - try { - const preprocessResult = await preprocessExecution({ - workflowId: payload.workflowId, + return runWithRequestContext({ requestId }, async () => { + logger.info(`[${requestId}] Starting workflow execution job: ${workflowId}`, { userId: payload.userId, - triggerType: triggerType, - executionId: executionId, - requestId: requestId, - checkRateLimit: true, - checkDeployment: true, - loggingSession: loggingSession, - triggerData: { correlation }, + triggerType: payload.triggerType, + executionId, }) - if (!preprocessResult.success) { - logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`, { - workflowId, - statusCode: preprocessResult.error?.statusCode, - }) - - throw new Error(preprocessResult.error?.message || 'Preprocessing failed') - } + const triggerType = (correlation.triggerType || 'api') as CoreTriggerType + const loggingSession = new LoggingSession(workflowId, executionId, triggerType, requestId) - const actorUserId = preprocessResult.actorUserId! - const workspaceId = preprocessResult.workflowRecord?.workspaceId - if (!workspaceId) { - throw new Error(`Workflow ${workflowId} has no associated workspace`) - } + try { + const preprocessResult = await preprocessExecution({ + workflowId: payload.workflowId, + userId: payload.userId, + triggerType: triggerType, + executionId: executionId, + requestId: requestId, + checkRateLimit: true, + checkDeployment: true, + loggingSession: loggingSession, + triggerData: { correlation }, + }) - logger.info(`[${requestId}] Preprocessing passed. Using actor: ${actorUserId}`) + if (!preprocessResult.success) { + logger.error(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`, { + workflowId, + statusCode: preprocessResult.error?.statusCode, + }) - const workflow = preprocessResult.workflowRecord! + throw new Error(preprocessResult.error?.message || 'Preprocessing failed') + } - const metadata: ExecutionMetadata = { - requestId, - executionId, - workflowId, - workspaceId, - userId: actorUserId, - sessionUserId: undefined, - workflowUserId: workflow.userId, - triggerType: payload.triggerType || 'api', - useDraftState: false, - startTime: new Date().toISOString(), - isClientSession: false, - callChain: payload.callChain, - correlation, - executionMode: payload.executionMode ?? 'async', - } + const actorUserId = preprocessResult.actorUserId! + const workspaceId = preprocessResult.workflowRecord?.workspaceId + if (!workspaceId) { + throw new Error(`Workflow ${workflowId} has no associated workspace`) + } - const snapshot = new ExecutionSnapshot( - metadata, - workflow, - payload.input, - workflow.variables || {}, - [] - ) + logger.info(`[${requestId}] Preprocessing passed. Using actor: ${actorUserId}`) - const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.async) + const workflow = preprocessResult.workflowRecord! - let result - try { - result = await executeWorkflowCore({ - snapshot, - callbacks: {}, - loggingSession, - includeFileBase64: true, - base64MaxBytes: undefined, - abortSignal: timeoutController.signal, + const metadata: ExecutionMetadata = { + requestId, + executionId, + workflowId, + workspaceId, + userId: actorUserId, + sessionUserId: undefined, + workflowUserId: workflow.userId, + triggerType: payload.triggerType || 'api', + useDraftState: false, + startTime: new Date().toISOString(), + isClientSession: false, + callChain: payload.callChain, + correlation, + executionMode: payload.executionMode ?? 'async', + } + + const snapshot = new ExecutionSnapshot( + metadata, + workflow, + payload.input, + workflow.variables || {}, + [] + ) + + const timeoutController = createTimeoutAbortController( + preprocessResult.executionTimeout?.async + ) + + let result + try { + result = await executeWorkflowCore({ + snapshot, + callbacks: {}, + loggingSession, + includeFileBase64: true, + base64MaxBytes: undefined, + abortSignal: timeoutController.signal, + }) + } finally { + timeoutController.cleanup() + } + + if ( + result.status === 'cancelled' && + timeoutController.isTimedOut() && + timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) + logger.info(`[${requestId}] Workflow execution timed out`, { + timeoutMs: timeoutController.timeoutMs, + }) + await loggingSession.markAsFailed(timeoutErrorMessage) + } else { + await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) + } + + await loggingSession.waitForPostExecution() + + logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, { + success: result.success, + executionTime: result.metadata?.duration, + executionId, }) - } finally { - timeoutController.cleanup() - } - if ( - result.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Workflow execution timed out`, { - timeoutMs: timeoutController.timeoutMs, + return { + success: result.success, + workflowId: payload.workflowId, + executionId, + output: result.output, + executedAt: new Date().toISOString(), + metadata: payload.metadata, + } + } catch (error: unknown) { + logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, { + error: toError(error).message, + executionId, }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else { - await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession }) - } - await loggingSession.waitForPostExecution() + if (wasExecutionFinalizedByCore(error, executionId)) { + throw error + } - logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, { - success: result.success, - executionTime: result.metadata?.duration, - executionId, - }) + const executionResult = hasExecutionResult(error) ? error.executionResult : undefined + const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } - return { - success: result.success, - workflowId: payload.workflowId, - executionId, - output: result.output, - executedAt: new Date().toISOString(), - metadata: payload.metadata, - } - } catch (error: unknown) { - logger.error(`[${requestId}] Workflow execution failed: ${workflowId}`, { - error: toError(error).message, - executionId, - }) + await loggingSession.safeCompleteWithError({ + error: { + message: toError(error).message, + stackTrace: error instanceof Error ? error.stack : undefined, + }, + traceSpans, + }) - if (wasExecutionFinalizedByCore(error, executionId)) { throw error } - - const executionResult = hasExecutionResult(error) ? error.executionResult : undefined - const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } - - await loggingSession.safeCompleteWithError({ - error: { - message: toError(error).message, - stackTrace: error instanceof Error ? error.stack : undefined, - }, - traceSpans, - }) - - throw error - } + }) } export const workflowExecutionTask = task({ diff --git a/apps/sim/lib/core/utils/request.ts b/apps/sim/lib/core/utils/request.ts index ec9d300c080..3634c2f38c9 100644 --- a/apps/sim/lib/core/utils/request.ts +++ b/apps/sim/lib/core/utils/request.ts @@ -1,9 +1,13 @@ +import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' /** - * Generate a short request ID for correlation + * Generate a short request ID for correlation. If called inside a request + * context (see `withRouteHandler` and `runWithRequestContext`), returns the + * active request's ID so inline `[${requestId}]` log prefixes align with + * the auto-attached `{requestId=...}` logger metadata. */ export function generateRequestId(): string { - return generateId().slice(0, 8) + return getRequestContext()?.requestId ?? generateId().slice(0, 8) } /** 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..8a073ec4648 --- /dev/null +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -0,0 +1,58 @@ +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 ?? 'UNKNOWN' + const path = + request?.nextUrl?.pathname ?? new URL(request?.url ?? '/', 'http://localhost').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 ?? 0 + 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 }) + } else if (status > 0) { + logger.info('OK', { 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..844c513bf95 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,16 @@ 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, + method: reqCtx.method, + path: reqCtx.path, + ...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 +275,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 +371,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..698e243747c --- /dev/null +++ b/packages/logger/src/request-context.ts @@ -0,0 +1,46 @@ +export interface RequestContext { + requestId: string + method?: string + path?: string +} + +/** + * AsyncLocalStorage is only available in Node.js. In Edge/browser contexts + * we fall back to a no-op implementation so the logger import doesn't break. + */ +interface Storage { + getStore(): T | undefined + run(store: T, fn: () => R): R +} + +let storage: Storage + +if (typeof globalThis.process !== 'undefined' && globalThis.process.versions?.node) { + // Node.js — use real AsyncLocalStorage + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AsyncLocalStorage } = require('node:async_hooks') as typeof import('node:async_hooks') + storage = new AsyncLocalStorage() +} else { + // Edge / browser — no-op + storage = { + getStore: () => undefined, + run: (_store: RequestContext, fn: () => R) => fn(), + } +} + +/** + * 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 storage.run(context, fn) +} + +/** + * Returns the current request context, or undefined if called outside + * of a `runWithRequestContext` scope. + */ +export function getRequestContext(): RequestContext | undefined { + return storage.getStore() +} diff --git a/packages/testing/src/mocks/logger.mock.ts b/packages/testing/src/mocks/logger.mock.ts index a71eedb1ecc..291b91df5db 100644 --- a/packages/testing/src/mocks/logger.mock.ts +++ b/packages/testing/src/mocks/logger.mock.ts @@ -37,6 +37,8 @@ export function createMockLogger() { export const loggerMock = { createLogger: vi.fn(() => createMockLogger()), logger: createMockLogger(), + runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), + getRequestContext: vi.fn(() => undefined), } /**