import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull } from 'drizzle-orm' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' import { performFullDeploy } from '@/lib/workflows/orchestration/deploy' const logger = createLogger('ChatDeployOrchestration') export interface ChatDeployPayload { workflowId: string userId: string identifier: string title: string description?: string /** Summary of what changed in this deployment version (distinct from the chat-facing `description`). */ versionDescription?: string /** Short name/label for this deployment version. */ versionName?: string customizations?: { primaryColor?: string; welcomeMessage?: string; imageUrl?: string } authType?: 'public' | 'password' | 'email' | 'sso' password?: string | null allowedEmails?: string[] outputConfigs?: Array<{ blockId: string; path: string }> workspaceId?: string | null } export interface PerformChatDeployResult { success: boolean chatId?: string chatUrl?: string deployedAt?: Date | null version?: number error?: string } /** * Deploys a chat: deploys the underlying workflow via `performFullDeploy`, * encrypts passwords, creates or updates the chat record, fires telemetry, * and records an audit entry. Both the chat API route and the copilot * `deploy_chat` tool must use this function. */ export async function performChatDeploy( params: ChatDeployPayload ): Promise { const { workflowId, userId, identifier, title, description = '', authType = 'public', password, allowedEmails = [], outputConfigs = [], } = params const customizations = { primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)', welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?', ...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}), } const deployResult = await performFullDeploy({ workflowId, userId, versionDescription: params.versionDescription, versionName: params.versionName, }) if (!deployResult.success) { return { success: false, error: deployResult.error || 'Failed to deploy workflow' } } let encryptedPassword: string | null = null if (authType === 'password' && password) { const { encrypted } = await encryptSecret(password) encryptedPassword = encrypted } const [existingDeployment] = await db .select() .from(chat) .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) .limit(1) let chatId: string if (existingDeployment) { chatId = existingDeployment.id let passwordToStore: string | null if (authType === 'password') { passwordToStore = encryptedPassword || existingDeployment.password } else { passwordToStore = null } await db .update(chat) .set({ identifier, title, description: description || null, customizations, authType, password: passwordToStore, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, updatedAt: new Date(), }) .where(eq(chat.id, chatId)) } else { chatId = generateId() await db.insert(chat).values({ id: chatId, workflowId, userId, identifier, title, description: description || null, customizations, isActive: true, authType, password: encryptedPassword, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, createdAt: new Date(), updatedAt: new Date(), }) } const baseUrl = getBaseUrl() let chatUrl: string try { const url = new URL(baseUrl) let host = url.host if (host.startsWith('www.')) { host = host.substring(4) } chatUrl = `${url.protocol}//${host}/chat/${identifier}` } catch { chatUrl = `${baseUrl}/chat/${identifier}` } logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) try { const { PlatformEvents } = await import('@/lib/core/telemetry') PlatformEvents.chatDeployed({ chatId, workflowId, authType, hasOutputConfigs: outputConfigs.length > 0, }) } catch (_e) { // Telemetry is best-effort } recordAudit({ workspaceId: params.workspaceId || null, actorId: userId, action: AuditAction.CHAT_DEPLOYED, resourceType: AuditResourceType.CHAT, resourceId: chatId, resourceName: title, description: `Deployed chat "${title}"`, metadata: { workflowId, identifier, authType, chatUrl, isUpdate: !!existingDeployment, hasOutputConfigs: outputConfigs.length > 0, hasCustomizations: !!( params.customizations?.primaryColor || params.customizations?.welcomeMessage || params.customizations?.imageUrl ), }, }) return { success: true, chatId, chatUrl, deployedAt: deployResult.deployedAt, version: deployResult.version, } } export interface PerformChatUndeployParams { chatId: string userId: string workspaceId?: string | null } export interface PerformChatUndeployResult { success: boolean error?: string } /** * Undeploys a chat: deletes the chat record and records an audit entry. * Both the chat manage DELETE route and the copilot `deploy_chat` undeploy * action must use this function. */ export async function performChatUndeploy( params: PerformChatUndeployParams ): Promise { const { chatId, userId, workspaceId } = params const [chatRecord] = await db .select({ title: chat.title, workflowId: chat.workflowId, identifier: chat.identifier, authType: chat.authType, }) .from(chat) .where(eq(chat.id, chatId)) .limit(1) if (!chatRecord) { return { success: false, error: 'Chat not found' } } await db.delete(chat).where(eq(chat.id, chatId)) logger.info(`Chat "${chatId}" deleted successfully`) recordAudit({ workspaceId: workspaceId || null, actorId: userId, action: AuditAction.CHAT_DELETED, resourceType: AuditResourceType.CHAT, resourceId: chatId, resourceName: chatRecord.title || chatId, description: `Deleted chat deployment "${chatRecord.title || chatId}"`, metadata: { workflowId: chatRecord.workflowId || undefined, identifier: chatRecord.identifier || undefined, authType: chatRecord.authType || undefined, }, }) return { success: true } }