import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deployedChatPostContract } from '@/lib/api/contracts/chats' import { parseRequest } from '@/lib/api/server' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { env } from '@/lib/core/config/env' import { 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' import { assertChatEmbedAllowed, setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('ChatIdentifierAPI') interface ChatConfigSource { id: string title: string description: string | null customizations: unknown authType: string | null outputConfigs: unknown } function toChatConfigResponse(deployment: ChatConfigSource) { return { id: deployment.id, title: deployment.title, description: deployment.description, customizations: deployment.customizations, authType: deployment.authType, outputConfigs: deployment.outputConfigs, } } export const dynamic = 'force-dynamic' export const runtime = 'nodejs' const CHAT_MAX_REQUEST_BYTES = Number.parseInt(env.CHAT_MAX_REQUEST_BYTES, 10) || 220 * 1024 * 1024 export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => { const { identifier } = await context.params const requestId = generateRequestId() const ticket = tryAdmit() if (!ticket) { return admissionRejectedResponse() } try { const parsed = await parseRequest(deployedChatPostContract, request, context, { maxBodyBytes: CHAT_MAX_REQUEST_BYTES, validationErrorResponse: (err) => { const message = err.issues.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ') return createErrorResponse(`Invalid request body: ${message}`, 400, 'VALIDATION_ERROR') }, invalidJsonResponse: () => createErrorResponse('Invalid request body', 400), }) if (!parsed.success) return parsed.response const parsedBody = parsed.data.body 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 createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] 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 workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.warn( `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace` ) return createErrorResponse('This chat is currently unavailable', 403) } 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: [], }) return createErrorResponse('This chat is currently unavailable', 403) } const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId) if (embedBlock) return embedBlock const authResult = await validateChatAuth(requestId, deployment, request, parsedBody) if (!authResult.authorized) { const response = createErrorResponse( authResult.error || 'Authentication required', authResult.status || 401 ) if (authResult.status === 429 && authResult.retryAfterMs !== undefined) { response.headers.set('Retry-After', String(Math.ceil(authResult.retryAfterMs / 1000))) } return response } const { input, password, email, conversationId, files } = parsedBody if ((password || email) && !input) { const response = createSuccessResponse(toChatConfigResponse(deployment)) if (deployment.authType !== 'sso') { setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password) } return response } if (!input && (!files || files.length === 0)) { return createErrorResponse('No input provided', 400) } 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 createErrorResponse( preprocessResult.error?.message || 'Failed to process request', preprocessResult.error?.statusCode || 500 ) } const { actorUserId, workflowRecord } = preprocessResult const workspaceOwnerId = actorUserId! const workspaceId = workflowRecord?.workspaceId if (!workspaceId) { logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`) // preprocessExecution reserved a billing concurrency slot; release it on // this early exit since no LoggingSession will finalize to free it. await releaseExecutionSlot(executionId) return createErrorResponse('Workflow has no associated workspace', 500) } 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) } } 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 workflowInput: any = { input, conversationId } if (files && Array.isArray(files) && files.length > 0) { const executionContext = { workspaceId, workflowId: deployment.workflowId, executionId, } 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, workspaceId, workflowId: deployment.workflowId, userId: workspaceOwnerId, 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 streamResponse } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) // Setup failed before the workflow stream took over slot release; // free the reserved billing slot (idempotent if already released). await releaseExecutionSlot(executionId) return createErrorResponse(error.message || 'Failed to process request', 500) } } catch (error: any) { logger.error(`[${requestId}] Error processing chat request:`, error) return createErrorResponse(error.message || 'Failed to process request', 500) } finally { ticket.release() } } ) 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: chat.id, title: chat.title, description: chat.description, customizations: chat.customizations, isActive: chat.isActive, workflowId: chat.workflowId, authType: chat.authType, password: chat.password, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, }) .from(chat) .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) .limit(1) if (deploymentResult.length === 0) { logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`) return createErrorResponse('Chat not found', 404) } const deployment = deploymentResult[0] if (!deployment.isActive) { logger.warn(`[${requestId}] Chat is not active: ${identifier}`) return createErrorResponse('This chat is currently unavailable', 403) } const embedBlock = await assertChatEmbedAllowed(request, deployment.workflowId, requestId) if (embedBlock) return embedBlock const cookieName = `chat_auth_${deployment.id}` const authCookie = request.cookies.get(cookieName) if ( deployment.authType !== 'public' && deployment.authType !== 'sso' && authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password) ) { return createSuccessResponse(toChatConfigResponse(deployment)) } const authResult = await validateChatAuth(requestId, deployment, request) if (!authResult.authorized) { logger.info( `[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}` ) return createErrorResponse(authResult.error || 'Authentication required', 401) } return createSuccessResponse(toChatConfigResponse(deployment)) } catch (error: any) { logger.error(`[${requestId}] Error fetching chat info:`, error) return createErrorResponse(error.message || 'Failed to fetch chat information', 500) } } )