diff --git a/apps/sim/app/api/a2a/agents/[agentId]/route.ts b/apps/sim/app/api/a2a/agents/[agentId]/route.ts index 77f1fe149a3..688011352e3 100644 --- a/apps/sim/app/api/a2a/agents/[agentId]/route.ts +++ b/apps/sim/app/api/a2a/agents/[agentId]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { a2aAgent, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { generateAgentCard, generateSkillsFromWorkflow } from '@/lib/a2a/agent-card' import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types' @@ -31,8 +31,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise | null) ?? {} + const storedFingerprint = + typeof metadata.callerFingerprint === 'string' ? metadata.callerFingerprint : null + return !storedFingerprint || storedFingerprint === callerFingerprint +} + /** * GET - Returns the Agent Card (discovery document) */ @@ -87,7 +108,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise { if (!params?.message) { return NextResponse.json( @@ -378,6 +435,13 @@ async function handleMessageSend( ) } + if (callerFingerprint && !hasCallerAccessToTask(existingTask, callerFingerprint)) { + return NextResponse.json( + createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), + { status: 404 } + ) + } + if (isTerminalState(existingTask.status as TaskState)) { return NextResponse.json( createError(id, A2A_ERROR_CODES.TASK_ALREADY_COMPLETE, 'Task already in terminal state'), @@ -410,7 +474,7 @@ async function handleMessageSend( sessionId: contextId || null, status: 'working', messages: history, - metadata: {}, + metadata: callerFingerprint ? { callerFingerprint } : {}, createdAt: new Date(), updatedAt: new Date(), }) @@ -431,6 +495,22 @@ async function handleMessageSend( try { const workflowInput = extractWorkflowInput(message) if (!workflowInput) { + await db + .update(a2aTask) + .set({ + status: 'failed', + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(a2aTask.id, taskId)) + + notifyTaskStateChange(taskId, 'failed').catch((err) => { + logger.error('Failed to trigger push notification for invalid input', { + taskId, + error: err, + }) + }) + return NextResponse.json( createError( id, @@ -453,8 +533,9 @@ async function handleMessageSend( }) const executeResult = await response.json() - - const finalState: TaskState = response.ok ? 'completed' : 'failed' + const executionId = executeResult.executionId || executeResult.metadata?.executionId + const executionSucceeded = response.ok && executeResult.success !== false + const finalState: TaskState = executionSucceeded ? 'completed' : 'failed' const agentContent = extractAgentContent(executeResult) const agentMessage = createAgentMessage(agentContent) @@ -470,7 +551,7 @@ async function handleMessageSend( status: finalState, messages: history, artifacts, - executionId: executeResult.metadata?.executionId, + executionId, completedAt: new Date(), updatedAt: new Date(), }) @@ -537,7 +618,8 @@ async function handleMessageStream( }, params: MessageSendParams, apiKey?: string | null, - executionUserId?: string + executionUserId?: string, + callerFingerprint?: string ): Promise { if (!params?.message) { return NextResponse.json( @@ -591,6 +673,13 @@ async function handleMessageStream( }) } + if (callerFingerprint && !hasCallerAccessToTask(existingTask, callerFingerprint)) { + await releaseLock(lockKey, lockValue) + return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { + status: 404, + }) + } + if (isTerminalState(existingTask.status as TaskState)) { await releaseLock(lockKey, lockValue) return NextResponse.json( @@ -624,7 +713,7 @@ async function handleMessageStream( sessionId: contextId || null, status: 'working', messages: history, - metadata: {}, + metadata: callerFingerprint ? { callerFingerprint } : {}, createdAt: new Date(), updatedAt: new Date(), }) @@ -672,6 +761,22 @@ async function handleMessageStream( const workflowInput = extractWorkflowInput(message) if (!workflowInput) { + await db + .update(a2aTask) + .set({ + status: 'failed', + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(a2aTask.id, taskId)) + + notifyTaskStateChange(taskId, 'failed').catch((err) => { + logger.error('Failed to trigger push notification for invalid streamed input', { + taskId, + error: err, + }) + }) + sendEvent('error', { code: A2A_ERROR_CODES.INVALID_PARAMS, message: 'Message must contain at least one part with content', @@ -705,6 +810,7 @@ async function handleMessageStream( } const contentType = response.headers.get('content-type') || '' + const streamingExecutionId = response.headers.get('X-Execution-Id') || undefined const isStreamingResponse = contentType.includes('text/event-stream') || contentType.includes('text/plain') @@ -713,14 +819,79 @@ async function handleMessageStream( const decoder = new TextDecoder() const contentChunks: string[] = [] let finalContent: string | undefined + let finalArtifacts: Artifact[] = [] + let sseBuffer = '' while (true) { const { done, value } = await reader.read() if (done) break - const rawChunk = decoder.decode(value, { stream: true }) - const parsed = parseWorkflowSSEChunk(rawChunk) + sseBuffer += decoder.decode(value, { stream: true }) + const frames = sseBuffer.split('\n\n') + sseBuffer = frames.pop() ?? '' + + for (const frame of frames) { + const parsed = parseWorkflowSSEChunk(frame) + + if (parsed.content) { + contentChunks.push(parsed.content) + sendEvent('message', { + kind: 'message', + taskId, + contextId, + role: 'agent', + parts: [{ kind: 'text', text: parsed.content }], + final: false, + }) + } + + if (parsed.finalContent) { + finalContent = parsed.finalContent + } + if (parsed.finalArtifacts) { + finalArtifacts = parsed.finalArtifacts + } + if (parsed.terminalState === 'canceled') { + const agentMessage = createAgentMessage(finalContent || 'Task canceled') + agentMessage.taskId = taskId + if (contextId) agentMessage.contextId = contextId + history.push(agentMessage) + + await db + .update(a2aTask) + .set({ + status: 'canceled', + messages: history, + executionId: streamingExecutionId, + artifacts: finalArtifacts, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(a2aTask.id, taskId)) + + notifyTaskStateChange(taskId, 'canceled').catch((err) => { + logger.error('Failed to trigger push notification', { taskId, error: err }) + }) + + sendEvent('task', { + kind: 'task', + id: taskId, + contextId, + status: { state: 'canceled', timestamp: new Date().toISOString() }, + history, + artifacts: finalArtifacts, + }) + return + } + + if (parsed.finalSuccess === false) { + throw new Error('Workflow execution failed') + } + } + } + if (sseBuffer.trim().length > 0) { + const parsed = parseWorkflowSSEChunk(sseBuffer) if (parsed.content) { contentChunks.push(parsed.content) sendEvent('message', { @@ -732,10 +903,15 @@ async function handleMessageStream( final: false, }) } - if (parsed.finalContent) { finalContent = parsed.finalContent } + if (parsed.finalArtifacts) { + finalArtifacts = parsed.finalArtifacts + } + if (parsed.finalSuccess === false) { + throw new Error('Workflow execution failed') + } } const accumulatedContent = contentChunks.join('') @@ -753,6 +929,8 @@ async function handleMessageStream( .set({ status: 'completed', messages: history, + executionId: streamingExecutionId, + artifacts: finalArtifacts, completedAt: new Date(), updatedAt: new Date(), }) @@ -768,10 +946,11 @@ async function handleMessageStream( contextId, status: { state: 'completed', timestamp: new Date().toISOString() }, history, - artifacts: [], + artifacts: finalArtifacts, }) } else { const result = await response.json() + const executionSucceeded = result.success !== false const content = extractAgentContent(result) @@ -794,24 +973,29 @@ async function handleMessageStream( await db .update(a2aTask) .set({ - status: 'completed', + status: executionSucceeded ? 'completed' : 'failed', messages: history, artifacts, - executionId: result.metadata?.executionId, + executionId: result.executionId || result.metadata?.executionId, completedAt: new Date(), updatedAt: new Date(), }) .where(eq(a2aTask.id, taskId)) - notifyTaskStateChange(taskId, 'completed').catch((err) => { - logger.error('Failed to trigger push notification', { taskId, error: err }) - }) + notifyTaskStateChange(taskId, executionSucceeded ? 'completed' : 'failed').catch( + (err) => { + logger.error('Failed to trigger push notification', { taskId, error: err }) + } + ) sendEvent('task', { kind: 'task', id: taskId, contextId, - status: { state: 'completed', timestamp: new Date().toISOString() }, + status: { + state: executionSucceeded ? 'completed' : 'failed', + timestamp: new Date().toISOString(), + }, history, artifacts, }) @@ -874,7 +1058,8 @@ async function handleMessageStream( async function handleTaskGet( id: string | number, agentId: string, - params: TaskIdParams + params: TaskIdParams, + callerFingerprint?: string ): Promise { if (!params?.id) { return NextResponse.json( @@ -888,7 +1073,7 @@ async function handleTaskGet( ? params.historyLength : undefined - const task = await getTaskForAgent(params.id, agentId) + const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { @@ -915,7 +1100,8 @@ async function handleTaskGet( async function handleTaskCancel( id: string | number, agentId: string, - params: TaskIdParams + params: TaskIdParams, + callerFingerprint?: string ): Promise { if (!params?.id) { return NextResponse.json( @@ -924,7 +1110,7 @@ async function handleTaskCancel( ) } - const task = await getTaskForAgent(params.id, agentId) + const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { @@ -989,7 +1175,8 @@ async function handleTaskResubscribe( request: NextRequest, id: string | number, agentId: string, - params: TaskIdParams + params: TaskIdParams, + callerFingerprint?: string ): Promise { if (!params?.id) { return NextResponse.json( @@ -998,7 +1185,7 @@ async function handleTaskResubscribe( ) } - const task = await getTaskForAgent(params.id, agentId) + const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { @@ -1202,7 +1389,8 @@ async function handleTaskResubscribe( async function handlePushNotificationSet( id: string | number, agentId: string, - params: PushNotificationSetParams + params: PushNotificationSetParams, + callerFingerprint?: string ): Promise { if (!params?.id) { return NextResponse.json( @@ -1229,7 +1417,7 @@ async function handlePushNotificationSet( ) } - const task = await getTaskForAgent(params.id, agentId) + const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { @@ -1281,7 +1469,8 @@ async function handlePushNotificationSet( async function handlePushNotificationGet( id: string | number, agentId: string, - params: TaskIdParams + params: TaskIdParams, + callerFingerprint?: string ): Promise { if (!params?.id) { return NextResponse.json( @@ -1290,7 +1479,7 @@ async function handlePushNotificationGet( ) } - const task = await getTaskForAgent(params.id, agentId) + const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { @@ -1325,7 +1514,8 @@ async function handlePushNotificationGet( async function handlePushNotificationDelete( id: string | number, agentId: string, - params: TaskIdParams + params: TaskIdParams, + callerFingerprint?: string ): Promise { if (!params?.id) { return NextResponse.json( @@ -1334,7 +1524,7 @@ async function handlePushNotificationDelete( ) } - const task = await getTaskForAgent(params.id, agentId) + const task = await getTaskForAgent(params.id, agentId, callerFingerprint) if (!task) { return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), { diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index cd8af919c7c..84a824238c2 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -93,9 +93,12 @@ vi.mock('@sim/db', () => ({ vi.mock('@sim/db/schema', () => ({ chat: { id: 'id', + identifier: 'identifier', authType: 'authType', allowedEmails: 'allowedEmails', title: 'title', + isActive: 'isActive', + archivedAt: 'archivedAt', }, verification: { id: 'id', @@ -112,6 +115,7 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })), gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })), lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })), + isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })), })) vi.mock('@/lib/core/storage', () => ({ diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index e518ceb28a5..983e65d9850 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' import { chat, verification } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, gt } from 'drizzle-orm' +import { and, eq, gt, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { renderOTPEmail } from '@/components/emails' @@ -128,7 +128,7 @@ export async function POST( title: chat.title, }) .from(chat) - .where(eq(chat.identifier, identifier)) + .where(and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))) .limit(1) if (deploymentResult.length === 0) { @@ -218,7 +218,7 @@ export async function PUT( authType: chat.authType, }) .from(chat) - .where(eq(chat.identifier, identifier)) + .where(and(eq(chat.identifier, identifier), eq(chat.isActive, true), isNull(chat.archivedAt))) .limit(1) if (deploymentResult.length === 0) { diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 772b1c56f46..024f5ff7e38 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' @@ -75,7 +75,7 @@ export async function POST( outputConfigs: chat.outputConfigs, }) .from(chat) - .where(eq(chat.identifier, identifier)) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) .limit(1) if (deploymentResult.length === 0) { @@ -91,7 +91,7 @@ export async function POST( const [workflowRecord] = await db .select({ workspaceId: workflow.workspaceId }) .from(workflow) - .where(eq(workflow.id, deployment.workflowId)) + .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) .limit(1) const workspaceId = workflowRecord?.workspaceId @@ -306,7 +306,7 @@ export async function GET( outputConfigs: chat.outputConfigs, }) .from(chat) - .where(eq(chat.identifier, identifier)) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) .limit(1) if (deploymentResult.length === 0) { diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index cf396007ee7..eefb5aa4666 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -70,7 +70,7 @@ vi.mock('@sim/db', () => ({ }, })) vi.mock('@sim/db/schema', () => ({ - chat: { id: 'id', identifier: 'identifier', userId: 'userId' }, + chat: { id: 'id', identifier: 'identifier', userId: 'userId', archivedAt: 'archivedAt' }, })) vi.mock('@/app/api/workflows/utils', () => ({ createSuccessResponse: mockCreateSuccessResponse, @@ -89,7 +89,9 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ deployWorkflow: mockDeployWorkflow, })) vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), + and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), + eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), })) import { DELETE, GET, PATCH } from '@/app/api/chat/manage/[id]/route' diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 6f09bd2101c..1585d380057 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -132,7 +132,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const existingIdentifier = await db .select() .from(chat) - .where(eq(chat.identifier, identifier)) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) .limit(1) if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) { diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index 5488110aafc..ef906239e84 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -52,7 +52,10 @@ export async function GET(_request: NextRequest) { } // Get the user's chat deployments - const deployments = await db.select().from(chat).where(eq(chat.userId, session.user.id)) + const deployments = await db + .select() + .from(chat) + .where(and(eq(chat.userId, session.user.id), isNull(chat.archivedAt))) return createSuccessResponse({ deployments }) } catch (error: any) { @@ -108,7 +111,11 @@ export async function POST(request: NextRequest) { // Check identifier availability and workflow access in parallel const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ - db.select().from(chat).where(eq(chat.identifier, identifier)).limit(1), + db + .select() + .from(chat) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) + .limit(1), checkWorkflowAccessForChatCreation(workflowId, session.user.id), ]) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index ae9b7f4437f..8d1147c1c83 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { isEmailAllowed, @@ -60,7 +60,7 @@ export async function checkChatAccess( }) .from(chat) .innerJoin(workflow, eq(chat.workflowId, workflow.id)) - .where(eq(chat.id, chatId)) + .where(and(eq(chat.id, chatId), isNull(chat.archivedAt))) .limit(1) if (chatData.length === 0) { diff --git a/apps/sim/app/api/chat/validate/route.ts b/apps/sim/app/api/chat/validate/route.ts index 0aecbd66f03..6d9fe749b36 100644 --- a/apps/sim/app/api/chat/validate/route.ts +++ b/apps/sim/app/api/chat/validate/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -45,7 +45,7 @@ export async function GET(request: NextRequest) { const existingChat = await db .select({ id: chat.id }) .from(chat) - .where(eq(chat.identifier, validatedIdentifier)) + .where(and(eq(chat.identifier, validatedIdentifier), isNull(chat.archivedAt))) .limit(1) const isAvailable = existingChat.length === 0 diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index 5dccbc21c00..b3766ab156e 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -6,10 +6,11 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDelete, mockWhere, mockGetSession } = vi.hoisted(() => ({ +const { mockDelete, mockWhere, mockGetSession, mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ mockDelete: vi.fn(), mockWhere: vi.fn(), mockGetSession: vi.fn(), + mockGetAccessibleCopilotChat: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -26,6 +27,7 @@ vi.mock('@sim/db/schema', () => ({ copilotChats: { id: 'id', userId: 'userId', + workspaceId: 'workspaceId', }, })) @@ -33,7 +35,15 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), })) -import { DELETE } from '@/app/api/copilot/chat/delete/route' +vi.mock('@/lib/copilot/chat-lifecycle', () => ({ + getAccessibleCopilotChat: mockGetAccessibleCopilotChat, +})) + +vi.mock('@/lib/copilot/task-events', () => ({ + taskPubSub: { publishStatusChanged: vi.fn() }, +})) + +import { DELETE } from './route' function createMockRequest(method: string, body: Record): NextRequest { return new NextRequest('http://localhost:3000/api/copilot/chat/delete', { @@ -49,8 +59,10 @@ describe('Copilot Chat Delete API Route', () => { mockGetSession.mockResolvedValue(null) + const mockReturning = vi.fn().mockResolvedValue([{ workspaceId: 'ws-1' }]) + mockWhere.mockReturnValue({ returning: mockReturning }) mockDelete.mockReturnValue({ where: mockWhere }) - mockWhere.mockResolvedValue([]) + mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) }) afterEach(() => { @@ -75,8 +87,6 @@ describe('Copilot Chat Delete API Route', () => { it('should successfully delete a chat', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockWhere.mockResolvedValueOnce([{ id: 'chat-123' }]) - const req = createMockRequest('DELETE', { chatId: 'chat-123', }) @@ -154,7 +164,7 @@ describe('Copilot Chat Delete API Route', () => { it('should delete chat even if it does not exist (idempotent)', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockWhere.mockResolvedValueOnce([]) + mockGetAccessibleCopilotChat.mockResolvedValueOnce(null) const req = createMockRequest('DELETE', { chatId: 'non-existent-chat', diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index 2197c0f076f..652f732e676 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/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 { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' import { taskPubSub } from '@/lib/copilot/task-events' const logger = createLogger('DeleteChatAPI') @@ -23,6 +24,11 @@ export async function DELETE(request: NextRequest) { const body = await request.json() const parsed = DeleteChatSchema.parse(body) + const chat = await getAccessibleCopilotChat(parsed.chatId, session.user.id) + if (!chat) { + return NextResponse.json({ success: true }) + } + const [deleted] = await db .delete(copilotChats) .where(and(eq(copilotChats.id, parsed.chatId), eq(copilotChats.userId, session.user.id))) diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts index abdac984345..3dedbea5cba 100644 --- a/apps/sim/app/api/copilot/chat/rename/route.ts +++ b/apps/sim/app/api/copilot/chat/rename/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 { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' import { taskPubSub } from '@/lib/copilot/task-events' const logger = createLogger('RenameChatAPI') @@ -24,6 +25,11 @@ export async function PATCH(request: NextRequest) { const body = await request.json() const { chatId, title } = RenameChatSchema.parse(body) + const chat = await getAccessibleCopilotChat(chatId, session.user.id) + if (!chat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + const now = new Date() const [updated] = await db .update(copilotChats) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index d3c6e169781..fe93d10b5f4 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -5,7 +5,7 @@ import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' +import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload' import { createSSEStream, @@ -21,8 +21,14 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' -import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + authorizeWorkflowByWorkspacePermission, + resolveWorkflowIdForUser, +} from '@/lib/workflows/utils' +import { + assertActiveWorkspaceAccess, + getUserEntityPermissions, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatAPI') @@ -101,6 +107,7 @@ export async function POST(req: NextRequest) { userMessageId, chatId, workflowId: providedWorkflowId, + workspaceId: requestedWorkspaceId, workflowName, model, mode, @@ -133,7 +140,8 @@ export async function POST(req: NextRequest) { const resolved = await resolveWorkflowIdForUser( authenticatedUserId, providedWorkflowId, - workflowName + workflowName, + requestedWorkspaceId ) if (!resolved) { return createBadRequestResponse( @@ -218,6 +226,10 @@ export async function POST(req: NextRequest) { conversationHistory = Array.isArray(chatResult.conversationHistory) ? chatResult.conversationHistory : [] + + if (chatId && !currentChat) { + return createBadRequestResponse('Chat not found') + } } const effectiveMode = mode === 'agent' ? 'build' : mode @@ -436,22 +448,7 @@ export async function GET(req: NextRequest) { } if (chatId) { - const [chat] = await db - .select({ - id: copilotChats.id, - title: copilotChats.title, - model: copilotChats.model, - messages: copilotChats.messages, - planArtifact: copilotChats.planArtifact, - config: copilotChats.config, - conversationId: copilotChats.conversationId, - resources: copilotChats.resources, - createdAt: copilotChats.createdAt, - updatedAt: copilotChats.updatedAt, - }) - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, authenticatedUserId))) - .limit(1) + const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId) if (!chat) { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) @@ -479,6 +476,21 @@ export async function GET(req: NextRequest) { return createBadRequestResponse('workflowId, workspaceId, or chatId is required') } + if (workspaceId) { + await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) + } + + if (workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: authenticatedUserId, + action: 'read', + }) + if (!authorization.allowed) { + return createUnauthorizedResponse() + } + } + const scopeFilter = workflowId ? eq(copilotChats.workflowId, workflowId) : eq(copilotChats.workspaceId, workspaceId!) 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 4eceb7ea4b5..574c2241ede 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' import { COPILOT_MODES } from '@/lib/copilot/models' import { authenticateCopilotRequestSessionOnly, @@ -91,11 +92,7 @@ export async function POST(req: NextRequest) { } // Verify that the chat belongs to the user - const [chat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .limit(1) + const chat = await getAccessibleCopilotChat(chatId, userId) if (!chat) { return createNotFoundResponse('Chat not found or unauthorized') diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index aba03e59378..94d7519547b 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -13,6 +13,8 @@ const { mockAuthenticate, mockCreateUnauthorizedResponse, mockCreateInternalServerErrorResponse, + mockGetActiveWorkflowRecord, + mockCheckWorkspaceAccess, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockFrom: vi.fn(), @@ -21,6 +23,8 @@ const { mockAuthenticate: vi.fn(), mockCreateUnauthorizedResponse: vi.fn(), mockCreateInternalServerErrorResponse: vi.fn(), + mockGetActiveWorkflowRecord: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), })) vi.mock('@sim/db', () => ({ @@ -51,6 +55,14 @@ vi.mock('@/lib/copilot/request-helpers', () => ({ createInternalServerErrorResponse: mockCreateInternalServerErrorResponse, })) +vi.mock('@/lib/workflows/active-context', () => ({ + getActiveWorkflowRecord: mockGetActiveWorkflowRecord, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + import { GET } from '@/app/api/copilot/chats/route' describe('Copilot Chats List API Route', () => { @@ -68,6 +80,8 @@ describe('Copilot Chats List API Route', () => { mockCreateInternalServerErrorResponse.mockImplementation( (message: string) => new Response(JSON.stringify({ error: message }), { status: 500 }) ) + mockGetActiveWorkflowRecord.mockResolvedValue({ id: 'workflow-1' }) + mockCheckWorkspaceAccess.mockResolvedValue({ exists: true, hasAccess: true }) }) afterEach(() => { diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index e7b82e2d63f..5910cbb2bd8 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' +import { copilotChats, permissions, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { desc, eq } from 'drizzle-orm' +import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { authenticateCopilotRequestSessionOnly, @@ -18,20 +18,51 @@ export async function GET(_request: NextRequest) { return createUnauthorizedResponse() } - const chats = await db - .select({ + const visibleChats = await db + .selectDistinctOn([copilotChats.id], { id: copilotChats.id, title: copilotChats.title, workflowId: copilotChats.workflowId, + workspaceId: copilotChats.workspaceId, updatedAt: copilotChats.updatedAt, }) .from(copilotChats) - .where(eq(copilotChats.userId, userId)) - .orderBy(desc(copilotChats.updatedAt)) + .leftJoin(workflow, eq(copilotChats.workflowId, workflow.id)) + .leftJoin( + workspace, + or( + eq(workflow.workspaceId, workspace.id), + and(isNull(copilotChats.workflowId), eq(copilotChats.workspaceId, workspace.id)) + ) + ) + .leftJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspace.id), + eq(permissions.userId, userId) + ) + ) + .where( + and( + eq(copilotChats.userId, userId), + or( + and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)), + sql`${permissions.id} IS NOT NULL` + ), + or(isNull(workflow.id), isNull(workflow.archivedAt)), + or(isNull(workspace.id), isNull(workspace.archivedAt)) + ) + ) + .orderBy(copilotChats.id, desc(copilotChats.updatedAt)) - logger.info(`Retrieved ${chats.length} chats for user ${userId}`) + const sorted = [...visibleChats].sort( + (a, b) => new Date(b.updatedAt!).getTime() - new Date(a.updatedAt!).getTime() + ) - return NextResponse.json({ success: true, chats }) + logger.info(`Retrieved ${sorted.length} chats for user ${userId}`) + + return NextResponse.json({ success: true, chats: sorted }) } catch (error) { logger.error('Error fetching user copilot chats:', error) return createInternalServerErrorResponse('Failed to fetch user chats') diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index 5dad327cfd3..fe3246181d4 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -15,6 +15,7 @@ const { mockDeleteWhere, mockAuthorize, mockGetSession, + mockGetAccessibleCopilotChat, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockFrom: vi.fn(), @@ -24,6 +25,7 @@ const { mockDeleteWhere: vi.fn(), mockAuthorize: vi.fn(), mockGetSession: vi.fn(), + mockGetAccessibleCopilotChat: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -41,6 +43,10 @@ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorize, })) +vi.mock('@/lib/copilot/chat-lifecycle', () => ({ + getAccessibleCopilotChat: mockGetAccessibleCopilotChat, +})) + vi.mock('@sim/db', () => ({ db: { select: mockSelect, @@ -102,6 +108,7 @@ describe('Copilot Checkpoints Revert API Route', () => { // Mock delete chain mockDelete.mockReturnValue({ where: mockDeleteWhere }) mockDeleteWhere.mockResolvedValue(undefined) + mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) global.fetch = vi.fn() diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 7c58a143551..2edf7d2dec7 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/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 { z } from 'zod' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, @@ -49,6 +50,11 @@ export async function POST(request: NextRequest) { return createNotFoundResponse('Checkpoint not found or access denied') } + const chat = await getAccessibleCopilotChat(checkpoint.chatId, userId) + if (!chat) { + return createNotFoundResponse('Checkpoint not found or access denied') + } + const workflowData = await db .select() .from(workflowTable) diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index fcf6080c0c7..eedf688af37 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -16,6 +16,8 @@ const { mockValues, mockReturning, mockGetSession, + mockGetAccessibleCopilotChat, + mockAuthorizeWorkflowByWorkspacePermission, } = vi.hoisted(() => ({ mockSelect: vi.fn(), mockFrom: vi.fn(), @@ -26,6 +28,8 @@ const { mockValues: vi.fn(), mockReturning: vi.fn(), mockGetSession: vi.fn(), + mockGetAccessibleCopilotChat: vi.fn(), + mockAuthorizeWorkflowByWorkspacePermission: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -58,7 +62,15 @@ vi.mock('drizzle-orm', () => ({ desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), })) -import { GET, POST } from '@/app/api/copilot/checkpoints/route' +vi.mock('@/lib/copilot/chat-lifecycle', () => ({ + getAccessibleCopilotChat: mockGetAccessibleCopilotChat, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, +})) + +import { GET, POST } from './route' function createMockRequest(method: string, body: Record): NextRequest { return new NextRequest('http://localhost:3000/api/copilot/checkpoints', { @@ -84,6 +96,12 @@ describe('Copilot Checkpoints API Route', () => { mockLimit.mockResolvedValue([]) mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) + mockGetAccessibleCopilotChat.mockResolvedValue({ + id: 'chat-123', + userId: 'user-123', + workflowId: 'workflow-123', + }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ allowed: true }) }) afterEach(() => { @@ -123,8 +141,7 @@ describe('Copilot Checkpoints API Route', () => { it('should return 400 when chat not found or unauthorized', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - mockLimit.mockResolvedValue([]) + mockGetAccessibleCopilotChat.mockResolvedValueOnce(null) const req = createMockRequest('POST', { workflowId: 'workflow-123', @@ -142,12 +159,6 @@ describe('Copilot Checkpoints API Route', () => { it('should return 400 for invalid workflow state JSON', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const chat = { - id: 'chat-123', - userId: 'user-123', - } - mockLimit.mockResolvedValue([chat]) - const req = createMockRequest('POST', { workflowId: 'workflow-123', chatId: 'chat-123', @@ -164,12 +175,6 @@ describe('Copilot Checkpoints API Route', () => { it('should successfully create a checkpoint', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const chat = { - id: 'chat-123', - userId: 'user-123', - } - mockLimit.mockResolvedValue([chat]) - const checkpoint = { id: 'checkpoint-123', userId: 'user-123', @@ -219,12 +224,6 @@ describe('Copilot Checkpoints API Route', () => { it('should create checkpoint without messageId', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const chat = { - id: 'chat-123', - userId: 'user-123', - } - mockLimit.mockResolvedValue([chat]) - const checkpoint = { id: 'checkpoint-123', userId: 'user-123', @@ -254,12 +253,6 @@ describe('Copilot Checkpoints API Route', () => { it('should handle database errors during checkpoint creation', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - const chat = { - id: 'chat-123', - userId: 'user-123', - } - mockLimit.mockResolvedValue([chat]) - mockReturning.mockRejectedValue(new Error('Database insert failed')) const req = createMockRequest('POST', { @@ -278,7 +271,7 @@ describe('Copilot Checkpoints API Route', () => { it('should handle database errors during chat lookup', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockLimit.mockRejectedValue(new Error('Database query failed')) + mockGetAccessibleCopilotChat.mockRejectedValueOnce(new Error('Database query failed')) const req = createMockRequest('POST', { workflowId: 'workflow-123', diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index b1517986a01..58b4cde4bb2 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { copilotChats, workflowCheckpoints } from '@sim/db/schema' +import { workflowCheckpoints } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -11,6 +12,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('WorkflowCheckpointsAPI') @@ -42,23 +44,31 @@ export async function POST(req: NextRequest) { workflowId, chatId, messageId, - fullRequestBody: body, parsedData: { workflowId, chatId, messageId }, messageIdType: typeof messageId, messageIdExists: !!messageId, }) // Verify that the chat belongs to the user - const [chat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .limit(1) + const chat = await getAccessibleCopilotChat(chatId, userId) if (!chat) { return createBadRequestResponse('Chat not found or unauthorized') } + if (chat.workflowId !== workflowId) { + return createBadRequestResponse('Chat does not belong to the requested workflow') + } + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'write', + }) + if (!authorization.allowed) { + return createUnauthorizedResponse() + } + // Parse the workflow state to validate it's valid JSON let parsedWorkflowState try { @@ -134,6 +144,11 @@ export async function GET(req: NextRequest) { chatId, }) + const chat = await getAccessibleCopilotChat(chatId, userId) + if (!chat) { + return createBadRequestResponse('Chat not found or unauthorized') + } + // Fetch checkpoints for this user and chat const checkpoints = await db .select({ diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 3366e5830d7..972072d040c 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -1,15 +1,10 @@ import { db } from '@sim/db' -import { document, workspaceFile } from '@sim/db/schema' +import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq, like, or } from 'drizzle-orm' +import { and, eq, isNull, like, or } from 'drizzle-orm' import { getFileMetadata } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' -import { - BLOB_CHAT_CONFIG, - BLOB_KB_CONFIG, - S3_CHAT_CONFIG, - S3_KB_CONFIG, -} from '@/lib/uploads/config' +import { BLOB_CHAT_CONFIG, S3_CHAT_CONFIG } from '@/lib/uploads/config' import type { StorageConfig } from '@/lib/uploads/core/storage-client' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' @@ -30,11 +25,13 @@ export interface AuthorizationResult { * @returns Workspace file info or null if not found */ export async function lookupWorkspaceFileByKey( - key: string + key: string, + options?: { includeDeleted?: boolean } ): Promise<{ workspaceId: string; uploadedBy: string } | null> { try { + const { includeDeleted = false } = options ?? {} // Priority 1: Check new workspaceFiles table - const fileRecord = await getFileMetadataByKey(key, 'workspace') + const fileRecord = await getFileMetadataByKey(key, 'workspace', { includeDeleted }) if (fileRecord) { return { @@ -51,7 +48,11 @@ export async function lookupWorkspaceFileByKey( uploadedBy: workspaceFile.uploadedBy, }) .from(workspaceFile) - .where(eq(workspaceFile.key, key)) + .where( + includeDeleted + ? eq(workspaceFile.key, key) + : and(eq(workspaceFile.key, key), isNull(workspaceFile.deletedAt)) + ) .limit(1) if (legacyFile) { @@ -165,6 +166,17 @@ async function verifyWorkspaceFileAccess( isLocal?: boolean ): Promise { try { + const anyWorkspaceFileRecord = await getFileMetadataByKey(cloudKey, 'workspace', { + includeDeleted: true, + }) + if (anyWorkspaceFileRecord?.deletedAt) { + logger.warn('Workspace file access denied for archived file', { + userId, + cloudKey, + }) + return false + } + // Priority 1: Check database (most reliable, works for both local and cloud) const workspaceFileRecord = await lookupWorkspaceFileByKey(cloudKey) if (workspaceFileRecord) { @@ -351,94 +363,57 @@ async function verifyKBFileAccess( customConfig?: StorageConfig ): Promise { try { - // Priority 1: Check workspaceFiles table (new system) - const fileRecord = await getFileMetadataByKey(cloudKey, 'knowledge-base') - - if (fileRecord?.workspaceId) { - const permission = await getUserEntityPermissions(userId, 'workspace', fileRecord.workspaceId) - if (permission !== null) { - logger.debug('KB file access granted (workspaceFiles table)', { - userId, - workspaceId: fileRecord.workspaceId, - cloudKey, - }) - return true - } - logger.warn('User does not have workspace access for KB file', { - userId, - workspaceId: fileRecord.workspaceId, - cloudKey, + const activeKbFileDocuments = await db + .select({ + workspaceId: knowledgeBase.workspaceId, }) - return false - } - - // Priority 2: Check document table via fileUrl (legacy knowledge base files) - try { - // Try to find document with matching fileUrl - const documents = await db - .select({ - knowledgeBaseId: document.knowledgeBaseId, - }) - .from(document) - .where( + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + isNull(knowledgeBase.deletedAt), or( like(document.fileUrl, `%${cloudKey}%`), like(document.fileUrl, `%${encodeURIComponent(cloudKey)}%`) ) ) - .limit(10) // Limit to avoid scanning too many - - // Check each document's knowledge base for workspace access - for (const doc of documents) { - const { knowledgeBase } = await import('@sim/db/schema') - const [kb] = await db - .select({ - workspaceId: knowledgeBase.workspaceId, - }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, doc.knowledgeBaseId)) - .limit(1) - - if (kb?.workspaceId) { - const permission = await getUserEntityPermissions(userId, 'workspace', kb.workspaceId) - if (permission !== null) { - logger.debug('KB file access granted (document table lookup)', { - userId, - workspaceId: kb.workspaceId, - cloudKey, - }) - return true - } - } - } - } catch (docError) { - logger.debug('Document table lookup failed:', docError) - } + ) + .limit(10) - // Priority 3: Check cloud storage metadata - const config: StorageConfig = customConfig || (await getKBStorageConfig()) - const metadata = await getFileMetadata(cloudKey, config) - const workspaceId = metadata.workspaceId + for (const doc of activeKbFileDocuments) { + if (!doc.workspaceId) { + continue + } - if (workspaceId) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + const permission = await getUserEntityPermissions(userId, 'workspace', doc.workspaceId) if (permission !== null) { - logger.debug('KB file access granted (cloud metadata)', { + logger.debug('KB file access granted (active document lookup)', { userId, - workspaceId, + workspaceId: doc.workspaceId, cloudKey, }) return true } - logger.warn('User does not have workspace access for KB file', { - userId, - workspaceId, - cloudKey, - }) + } + + // KB file access must resolve through an active KB document. Metadata alone is not enough + // because parent archives intentionally keep the underlying file bytes around for history. + const fileRecord = await getFileMetadataByKey(cloudKey, 'knowledge-base', { + includeDeleted: true, + }) + + if (fileRecord?.deletedAt) { + logger.warn('KB file access denied for deleted file metadata', { userId, cloudKey }) return false } - logger.warn('KB file missing workspaceId in all sources', { cloudKey, userId }) + logger.warn('KB file access denied because no active KB document matched the file', { + cloudKey, + userId, + }) return false } catch (error) { logger.error('Error verifying KB file access', { cloudKey, userId, error }) @@ -604,31 +579,6 @@ export async function authorizeFileAccess( } } -/** - * Get KB storage configuration based on current storage provider - */ -async function getKBStorageConfig(): Promise { - const { USE_S3_STORAGE, USE_BLOB_STORAGE } = await import('@/lib/uploads/config') - - if (USE_BLOB_STORAGE) { - return { - containerName: BLOB_KB_CONFIG.containerName, - accountName: BLOB_KB_CONFIG.accountName, - accountKey: BLOB_KB_CONFIG.accountKey, - connectionString: BLOB_KB_CONFIG.connectionString, - } - } - - if (USE_S3_STORAGE) { - return { - bucket: S3_KB_CONFIG.bucket, - region: S3_KB_CONFIG.region, - } - } - - return {} -} - /** * Get chat storage configuration based on current storage provider */ diff --git a/apps/sim/app/api/files/delete/route.test.ts b/apps/sim/app/api/files/delete/route.test.ts index e63955015dc..5306829a132 100644 --- a/apps/sim/app/api/files/delete/route.test.ts +++ b/apps/sim/app/api/files/delete/route.test.ts @@ -123,6 +123,10 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ hasCloudStorage: mocks.mockHasCloudStorage, })) +vi.mock('@/lib/uploads/server/metadata', () => ({ + deleteFileMetadata: vi.fn().mockResolvedValue(undefined), +})) + vi.mock('@/lib/uploads/setup.server', () => ({})) vi.mock('fs/promises', () => ({ diff --git a/apps/sim/app/api/files/delete/route.ts b/apps/sim/app/api/files/delete/route.ts index 27350046122..64c24bf0cae 100644 --- a/apps/sim/app/api/files/delete/route.ts +++ b/apps/sim/app/api/files/delete/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from 'next/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import type { StorageContext } from '@/lib/uploads/config' import { deleteFile, hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { deleteFileMetadata } from '@/lib/uploads/server/metadata' import { extractStorageKey, inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' import { @@ -67,6 +68,7 @@ export async function POST(request: NextRequest) { key, context: storageContext, }) + await deleteFileMetadata(key) logger.info(`File successfully deleted: ${key}`) diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 96ab40c2f1e..41b9a6276cb 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +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 { getSession } from '@/lib/auth' +import { archiveWorkflowsByIdsInWorkspace } from '@/lib/workflows/lifecycle' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('FoldersIDAPI') @@ -151,7 +152,7 @@ export async function DELETE( const totalWorkflowsInWorkspace = await db .select({ id: workflow.id }) .from(workflow) - .where(eq(workflow.workspaceId, existingFolder.workspaceId)) + .where(and(eq(workflow.workspaceId, existingFolder.workspaceId), isNull(workflow.archivedAt))) if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) { return NextResponse.json( @@ -222,12 +223,20 @@ async function deleteFolderRecursively( const workflowsInFolder = await db .select({ id: workflow.id }) .from(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) + .where( + and( + eq(workflow.folderId, folderId), + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt) + ) + ) if (workflowsInFolder.length > 0) { - await db - .delete(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) + await archiveWorkflowsByIdsInWorkspace( + workspaceId, + workflowsInFolder.map((entry) => entry.id), + { requestId: `folder-${folderId}` } + ) stats.workflows += workflowsInFolder.length } @@ -252,7 +261,13 @@ async function countWorkflowsInFolderRecursively( const workflowsInFolder = await db .select({ id: workflow.id }) .from(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) + .where( + and( + eq(workflow.folderId, folderId), + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt) + ) + ) count += workflowsInFolder.length diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts index d6d4f019e4c..986a77610d8 100644 --- a/apps/sim/app/api/form/[identifier]/route.ts +++ b/apps/sim/app/api/form/[identifier]/route.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' import { form, workflow, workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { addCorsHeaders, validateAuthToken } from '@/lib/core/security/deployment' @@ -91,7 +91,7 @@ export async function POST( customizations: form.customizations, }) .from(form) - .where(eq(form.identifier, identifier)) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) .limit(1) if (deploymentResult.length === 0) { @@ -107,7 +107,7 @@ export async function POST( const [workflowRecord] = await db .select({ workspaceId: workflow.workspaceId }) .from(workflow) - .where(eq(workflow.id, deployment.workflowId)) + .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt))) .limit(1) const workspaceId = workflowRecord?.workspaceId @@ -312,7 +312,7 @@ export async function GET( showBranding: form.showBranding, }) .from(form) - .where(eq(form.identifier, identifier)) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) .limit(1) if (deploymentResult.length === 0) { diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index e64e52fb1e6..577363b8d9c 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -134,7 +134,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const existingIdentifier = await db .select() .from(form) - .where(eq(form.identifier, identifier)) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) .limit(1) if (existingIdentifier.length > 0) { @@ -241,7 +241,7 @@ export async function DELETE( return createErrorResponse('Form not found or access denied', 404) } - await db.update(form).set({ isActive: false, updatedAt: new Date() }).where(eq(form.id, id)) + await db.delete(form).where(eq(form.id, id)) logger.info(`Form ${id} deleted (soft delete)`) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index f940e8c605b..3becf417a44 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' @@ -73,7 +73,10 @@ export async function GET(request: NextRequest) { return createErrorResponse('Unauthorized', 401) } - const deployments = await db.select().from(form).where(eq(form.userId, session.user.id)) + const deployments = await db + .select() + .from(form) + .where(and(eq(form.userId, session.user.id), isNull(form.archivedAt))) return createSuccessResponse({ deployments }) } catch (error: any) { @@ -120,7 +123,11 @@ export async function POST(request: NextRequest) { // Check identifier availability and workflow access in parallel const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([ - db.select().from(form).where(eq(form.identifier, identifier)).limit(1), + db + .select() + .from(form) + .where(and(eq(form.identifier, identifier), isNull(form.archivedAt))) + .limit(1), checkWorkflowAccessForFormCreation(workflowId, session.user.id), ]) diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts index e39d210ac30..9f4bafd05ad 100644 --- a/apps/sim/app/api/form/utils.ts +++ b/apps/sim/app/api/form/utils.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { form, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest, NextResponse } from 'next/server' import { isEmailAllowed, @@ -57,7 +57,7 @@ export async function checkFormAccess( .select({ form: form, workflowWorkspaceId: workflow.workspaceId }) .from(form) .innerJoin(workflow, eq(form.workflowId, workflow.id)) - .where(eq(form.id, formId)) + .where(and(eq(form.id, formId), isNull(form.archivedAt))) .limit(1) if (formData.length === 0) { diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts index 8352149fd9b..0b2b8a076e7 100644 --- a/apps/sim/app/api/form/validate/route.ts +++ b/apps/sim/app/api/form/validate/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { form } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -50,7 +50,7 @@ export async function GET(request: NextRequest) { const existingForm = await db .select({ id: form.id }) .from(form) - .where(eq(form.identifier, validatedIdentifier)) + .where(and(eq(form.identifier, validatedIdentifier), isNull(form.archivedAt))) .limit(1) const isAvailable = existingForm.length === 0 diff --git a/apps/sim/app/api/jobs/[jobId]/route.ts b/apps/sim/app/api/jobs/[jobId]/route.ts index 14be54facb1..cb8a43a80de 100644 --- a/apps/sim/app/api/jobs/[jobId]/route.ts +++ b/apps/sim/app/api/jobs/[jobId]/route.ts @@ -40,6 +40,14 @@ export async function GET( logger.warn(`[${requestId}] Access denied to workflow ${job.metadata.workflowId}`) return createErrorResponse('Access denied', 403) } + + if (authResult.apiKeyType === 'workspace' && authResult.workspaceId) { + const { getWorkflowById } = await import('@/lib/workflows/utils') + const workflow = await getWorkflowById(job.metadata.workflowId as string) + if (!workflow?.workspaceId || workflow.workspaceId !== authResult.workspaceId) { + return createErrorResponse('API key is not authorized for this workspace', 403) + } + } } else if (job.metadata?.userId && job.metadata.userId !== authenticatedUserId) { logger.warn(`[${requestId}] Access denied to user ${job.metadata.userId}`) return createErrorResponse('Access denied', 403) 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 9e9cae71280..0b5e64c528d 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 @@ -39,6 +39,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -65,6 +66,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { .where( and( eq(document.connectorId, connectorId), + isNull(document.archivedAt), isNull(document.deletedAt), eq(document.userExcluded, false) ) @@ -88,6 +90,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { and( eq(document.connectorId, connectorId), eq(document.userExcluded, true), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -143,6 +146,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -166,12 +170,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { if (operation === 'restore') { const updated = await db .update(document) - .set({ userExcluded: false, deletedAt: null, enabled: true }) + .set({ userExcluded: false, enabled: true }) .where( and( eq(document.connectorId, connectorId), inArray(document.id, documentIds), eq(document.userExcluded, true), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -187,12 +192,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const updated = await db .update(document) - .set({ userExcluded: true }) + .set({ userExcluded: true, enabled: false }) .where( and( eq(document.connectorId, connectorId), inArray(document.id, documentIds), eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts index 10650c74f9f..c39c7866b69 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.test.ts @@ -12,9 +12,12 @@ const { mockCheckSession, mockCheckAccess, mockCheckWriteAccess, mockDbChain, mo where: vi.fn().mockReturnThis(), orderBy: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]), + execute: vi.fn().mockResolvedValue(undefined), + transaction: vi.fn(), insert: vi.fn().mockReturnThis(), values: vi.fn().mockResolvedValue(undefined), update: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(), returning: vi.fn().mockResolvedValue([]), } @@ -29,11 +32,19 @@ const { mockCheckSession, mockCheckAccess, mockCheckWriteAccess, mockDbChain, mo vi.mock('@sim/db', () => ({ db: mockDbChain })) vi.mock('@sim/db/schema', () => ({ - document: { connectorId: 'connectorId', deletedAt: 'deletedAt' }, + document: { + id: 'id', + connectorId: 'connectorId', + fileUrl: 'fileUrl', + archivedAt: 'archivedAt', + deletedAt: 'deletedAt', + }, + embedding: { documentId: 'documentId' }, knowledgeBase: { id: 'id', userId: 'userId' }, knowledgeConnector: { id: 'id', knowledgeBaseId: 'knowledgeBaseId', + archivedAt: 'archivedAt', deletedAt: 'deletedAt', connectorType: 'connectorType', credentialId: 'credentialId', @@ -61,6 +72,9 @@ vi.mock('@/connectors/registry', () => ({ vi.mock('@/lib/knowledge/tags/service', () => ({ cleanupUnusedTagDefinitions: vi.fn().mockResolvedValue(undefined), })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + deleteDocumentStorageFiles: vi.fn().mockResolvedValue(undefined), +})) import { DELETE, GET, PATCH } from '@/app/api/knowledge/[id]/connectors/[connectorId]/route' @@ -74,7 +88,12 @@ describe('Knowledge Connector By ID API Route', () => { mockDbChain.where.mockReturnThis() mockDbChain.orderBy.mockReturnThis() mockDbChain.limit.mockResolvedValue([]) + mockDbChain.execute.mockResolvedValue(undefined) + mockDbChain.transaction.mockImplementation( + async (callback: (tx: typeof mockDbChain) => unknown) => callback(mockDbChain) + ) mockDbChain.update.mockReturnThis() + mockDbChain.delete.mockReturnThis() mockDbChain.set.mockReturnThis() mockDbChain.returning.mockResolvedValue([]) }) @@ -190,9 +209,15 @@ describe('Knowledge Connector By ID API Route', () => { expect(response.status).toBe(401) }) - it('returns 200 on successful soft-delete', async () => { + it('returns 200 on successful hard-delete', async () => { mockCheckSession.mockResolvedValue({ success: true, userId: 'user-1' }) mockCheckWriteAccess.mockResolvedValue({ hasAccess: true }) + mockDbChain.where + .mockReturnValueOnce(mockDbChain) + .mockResolvedValueOnce([{ id: 'doc-1', fileUrl: '/api/uploads/test.txt' }]) + .mockReturnValueOnce(mockDbChain) + mockDbChain.limit.mockResolvedValueOnce([{ id: 'conn-456' }]) + mockDbChain.returning.mockResolvedValueOnce([{ id: 'conn-456' }]) const req = createMockRequest('DELETE') const response = await DELETE(req, { params: mockParams }) 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 8200acc289e..cfdca60afa0 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -1,17 +1,19 @@ import { db } from '@sim/db' import { document, + embedding, knowledgeBase, knowledgeConnector, knowledgeConnectorSyncLog, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { decryptApiKey } from '@/lib/api-key/crypto' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' +import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service' import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -53,6 +55,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -119,6 +122,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -210,6 +214,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -221,6 +226,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -235,7 +241,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { } /** - * DELETE /api/knowledge/[id]/connectors/[connectorId] - Soft-delete a connector + * DELETE /api/knowledge/[id]/connectors/[connectorId] - Hard-delete a connector */ export async function DELETE(request: NextRequest, { params }: RouteParams) { const requestId = generateRequestId() @@ -253,31 +259,69 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: status === 404 ? 'Not found' : 'Unauthorized' }, { status }) } - const now = new Date() - - await db - .update(knowledgeConnector) - .set({ deletedAt: now, status: 'paused', updatedAt: now }) + const existingConnector = await db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) .where( and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) + .limit(1) - // Soft-delete all documents belonging to this connector - await db - .update(document) - .set({ deletedAt: now }) - .where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt))) + if (existingConnector.length === 0) { + return NextResponse.json({ error: 'Connector not found' }, { status: 404 }) + } + + const connectorDocuments = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`) + + const docs = await tx + .select({ id: document.id, fileUrl: document.fileUrl }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + + const documentIds = docs.map((doc) => doc.id) + if (documentIds.length > 0) { + await tx.delete(embedding).where(inArray(embedding.documentId, documentIds)) + await tx.delete(document).where(inArray(document.id, documentIds)) + } + + const deletedConnectors = await tx + .delete(knowledgeConnector) + .where( + and( + eq(knowledgeConnector.id, connectorId), + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }) + + if (deletedConnectors.length === 0) { + throw new Error('Connector not found') + } + + return docs + }) + + await deleteDocumentStorageFiles(connectorDocuments, requestId) - // Reclaim tag slots that are no longer used by any active connector await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId).catch((error) => { logger.warn(`[${requestId}] Failed to cleanup tag definitions`, error) }) - logger.info(`[${requestId}] Soft-deleted connector ${connectorId} and its documents`) + logger.info(`[${requestId}] Hard-deleted connector ${connectorId} and its documents`) return NextResponse.json({ success: true }) } catch (error) { 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 b2f46899430..e6aae66eb27 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 @@ -38,6 +38,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { and( eq(knowledgeConnector.id, connectorId), eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 0421c4c66f3..c28cea60e46 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema' +import { knowledgeBase, knowledgeBaseTagDefinitions, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { and, desc, eq, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { encryptApiKey } from '@/lib/api-key/crypto' @@ -49,6 +49,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .where( and( eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -182,6 +183,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ syncIntervalMinutes > 0 ? new Date(now.getTime() + syncIntervalMinutes * 60 * 1000) : null await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + + const activeKb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) + + if (activeKb.length === 0) { + throw new Error('Knowledge base not found') + } + for (const [semanticId, slot] of Object.entries(tagSlotMapping)) { const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)! await createTagDefinition( @@ -229,6 +242,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ 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 }) } diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 258c9f44662..18f7af35ac2 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -368,8 +368,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: 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: isStorageLimitError ? 413 : 500 }) + return NextResponse.json( + { error: errorMessage }, + { status: isMissingKnowledgeBase ? 404 : isStorageLimitError ? 413 : 500 } + ) } } diff --git a/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts index b328b7d5b63..54318d6f600 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 @@ -30,7 +30,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) if (!accessCheck.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) } // Get existing definitions once and reuse 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 a141461ec09..08b56be3e24 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 @@ -1,9 +1,9 @@ import { randomUUID } from 'crypto' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { deleteTagDefinition } from '@/lib/knowledge/tags/service' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' export const dynamic = 'force-dynamic' @@ -22,17 +22,20 @@ export async function DELETE( `[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}` ) - const session = await getSession() - if (!session?.user?.id) { + const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) if (!accessCheck.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) } - const deletedTag = await deleteTagDefinition(tagId, requestId) + const deletedTag = await deleteTagDefinition(knowledgeBaseId, tagId, requestId) return NextResponse.json({ success: true, 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 cbc5ac90e67..bb107ca8677 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts @@ -5,7 +5,7 @@ import { z } from 'zod' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants' import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service' -import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' export const dynamic = 'force-dynamic' @@ -26,9 +26,12 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: // For session auth, verify KB access. Internal JWT is trusted. if (auth.authType === 'session' && auth.userId) { - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) if (!accessCheck.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) } } @@ -63,9 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // For session auth, verify KB access. Internal JWT is trusted. if (auth.authType === 'session' && auth.userId) { - const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId) + const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, auth.userId) if (!accessCheck.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) } } 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 788ae897583..8b311143ffb 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts @@ -24,7 +24,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) if (!accessCheck.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return NextResponse.json( + { error: accessCheck.notFound ? 'Not found' : 'Forbidden' }, + { status: accessCheck.notFound ? 404 : 403 } + ) } const usageStats = await getTagUsage(knowledgeBaseId, requestId) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 7523a0420bb..dfddc72e445 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { knowledgeConnector } from '@sim/db/schema' +import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, inArray, isNull, lte } from 'drizzle-orm' +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' @@ -32,11 +32,14 @@ export async function GET(request: NextRequest) { id: knowledgeConnector.id, }) .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( inArray(knowledgeConnector.status, ['active', 'error']), lte(knowledgeConnector.nextSyncAt, now), - isNull(knowledgeConnector.deletedAt) + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeBase.deletedAt) ) ) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index f266d90d8da..d6a80bab115 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -5,7 +5,11 @@ 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 { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' +import { + createKnowledgeBase, + getKnowledgeBases, + type KnowledgeBaseScope, +} from '@/lib/knowledge/service' const logger = createLogger('KnowledgeBaseAPI') @@ -61,8 +65,12 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url) const workspaceId = searchParams.get('workspaceId') + const scope = (searchParams.get('scope') ?? 'active') as KnowledgeBaseScope + if (!['active', 'archived', 'all'].includes(scope)) { + return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + } - const knowledgeBasesWithCounts = await getKnowledgeBases(session.user.id, workspaceId) + const knowledgeBasesWithCounts = await getKnowledgeBases(session.user.id, workspaceId, scope) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 686f7c19cc1..348a60ec71d 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -28,7 +28,7 @@ const logger = createLogger('VectorSearchAPI') const StructuredTagFilterSchema = z.object({ tagName: z.string(), tagSlot: z.string().optional(), - fieldType: z.enum(['text', 'number', 'date', 'boolean']).default('text'), + fieldType: z.enum(['text', 'number', 'date', 'boolean']).optional(), operator: z.string().default('eq'), value: z.union([z.string(), z.number(), z.boolean()]), valueTo: z.union([z.string(), z.number()]).optional(), @@ -117,17 +117,56 @@ export async function POST(request: NextRequest) { // Handle tag filters if (validatedData.tagFilters && accessibleKbIds.length > 0) { - const kbId = accessibleKbIds[0] - const tagDefs = await getDocumentTagDefinitions(kbId) + const kbTagDefs = await Promise.all( + accessibleKbIds.map(async (kbId) => ({ + kbId, + tagDefs: await getDocumentTagDefinitions(kbId), + })) + ) - // Create mapping from display name to tag slot and fieldType const displayNameToTagDef: Record = {} - tagDefs.forEach((def) => { - displayNameToTagDef[def.displayName] = { - tagSlot: def.tagSlot, - fieldType: def.fieldType, + for (const { kbId, tagDefs } of kbTagDefs) { + const perKbMap = new Map( + tagDefs.map((def) => [ + def.displayName, + { tagSlot: def.tagSlot, fieldType: def.fieldType }, + ]) + ) + + for (const filter of validatedData.tagFilters) { + const current = perKbMap.get(filter.tagName) + if (!current) { + if (accessibleKbIds.length > 1) { + return NextResponse.json( + { + error: `Tag "${filter.tagName}" does not exist in all selected knowledge bases. Search those knowledge bases separately.`, + }, + { status: 400 } + ) + } + continue + } + + const existing = displayNameToTagDef[filter.tagName] + if ( + existing && + (existing.tagSlot !== current.tagSlot || existing.fieldType !== current.fieldType) + ) { + return NextResponse.json( + { + error: `Tag "${filter.tagName}" is not mapped consistently across the selected knowledge bases. Search those knowledge bases separately.`, + }, + { status: 400 } + ) + } + + displayNameToTagDef[filter.tagName] = current } - }) + + logger.debug(`[${requestId}] Loaded tag definitions for KB ${kbId}`, { + tagCount: tagDefs.length, + }) + } // Validate all tag filters first const undefinedTags: string[] = [] @@ -171,8 +210,8 @@ export async function POST(request: NextRequest) { // Build structured filters with validated data structuredFilters = validatedData.tagFilters.map((filter) => { const tagDef = displayNameToTagDef[filter.tagName]! - const tagSlot = filter.tagSlot || tagDef.tagSlot - const fieldType = filter.fieldType || tagDef.fieldType + const tagSlot = tagDef.tagSlot + const fieldType = tagDef.fieldType logger.debug( `[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}` @@ -212,6 +251,28 @@ export async function POST(request: NextRequest) { ) } + if (workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + const workflowWorkspaceId = authorization.workflow?.workspaceId ?? null + if ( + workflowWorkspaceId && + accessChecks.some( + (accessCheck) => + accessCheck?.hasAccess && + accessCheck.knowledgeBase?.workspaceId !== workflowWorkspaceId + ) + ) { + return NextResponse.json( + { error: 'Knowledge base does not belong to the workflow workspace' }, + { status: 400 } + ) + } + } + let results: SearchResult[] const hasFilters = structuredFilters && structuredFilters.length > 0 diff --git a/apps/sim/app/api/knowledge/search/utils.ts b/apps/sim/app/api/knowledge/search/utils.ts index dc112fe24ab..8ca7e7c438a 100644 --- a/apps/sim/app/api/knowledge/search/utils.ts +++ b/apps/sim/app/api/knowledge/search/utils.ts @@ -17,7 +17,14 @@ export async function getDocumentNamesByIds( filename: document.filename, }) .from(document) - .where(and(inArray(document.id, uniqueIds), isNull(document.deletedAt))) + .where( + and( + inArray(document.id, uniqueIds), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) const documentNameMap: Record = {} documents.forEach((doc) => { @@ -313,6 +320,10 @@ async function executeTagFilterQuery( and( eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]), eq(embedding.enabled, true), + eq(document.enabled, true), + eq(document.processingStatus, 'completed'), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), ...tagFilterConditions ) @@ -326,6 +337,10 @@ async function executeTagFilterQuery( and( inArray(embedding.knowledgeBaseId, knowledgeBaseIds), eq(embedding.enabled, true), + eq(document.enabled, true), + eq(document.processingStatus, 'completed'), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), ...tagFilterConditions ) @@ -353,6 +368,10 @@ async function executeVectorSearchOnIds( .where( and( inArray(embedding.id, embeddingIds), + eq(document.enabled, true), + eq(document.processingStatus, 'completed'), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), sql`${embedding.embedding} <=> ${queryVector}::vector < ${distanceThreshold}` ) @@ -384,6 +403,10 @@ export async function handleTagOnlySearch(params: SearchParams): Promise ${queryVector}::vector < ${distanceThreshold}` ) @@ -455,6 +486,10 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise ${queryVector}::vector < ${distanceThreshold}` ) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 0e8debe701a..7e87035a08f 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -144,6 +144,18 @@ vi.mock('@sim/db', () => { }), transaction: vi.fn(async (fn: any) => { await fn({ + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ id: 'doc1' }]), + }), + }), + where: () => ({ + limit: () => Promise.resolve([{}]), + }), + }), + }), delete: () => ({ where: () => Promise.resolve(), }), diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index 355cca0edfd..60042ccccf1 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -293,7 +293,15 @@ export async function checkDocumentWriteAccess( externalId: document.externalId, }) .from(document) - .where(and(eq(document.id, documentId), isNull(document.deletedAt))) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) .limit(1) if (doc.length === 0) { @@ -333,6 +341,8 @@ export async function checkDocumentAccess( and( eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -376,6 +386,8 @@ export async function checkChunkAccess( and( eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) diff --git a/apps/sim/app/api/mcp/discover/route.ts b/apps/sim/app/api/mcp/discover/route.ts index 600e9362f6a..c386c304cc7 100644 --- a/apps/sim/app/api/mcp/discover/route.ts +++ b/apps/sim/app/api/mcp/discover/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { permissions, workflowMcpServer, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +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' @@ -26,12 +26,31 @@ export async function GET(request: NextRequest) { const userId = auth.userId + if (auth.apiKeyType === 'workspace' && !auth.workspaceId) { + return NextResponse.json( + { success: false, error: 'Workspace API key missing workspace scope' }, + { status: 403 } + ) + } + const userWorkspacePermissions = await db .select({ entityId: permissions.entityId }) .from(permissions) - .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + isNull(workspace.archivedAt) + ) + ) - const workspaceIds = userWorkspacePermissions.map((w) => w.entityId) + const workspaceIds = + auth.apiKeyType === 'workspace' && auth.workspaceId + ? userWorkspacePermissions + .map((w) => w.entityId) + .filter((workspaceId) => workspaceId === auth.workspaceId) + : userWorkspacePermissions.map((w) => w.entityId) if (workspaceIds.length === 0) { return NextResponse.json({ success: true, servers: [] }) @@ -49,11 +68,18 @@ export async function GET(request: NextRequest) { SELECT COUNT(*)::int FROM "workflow_mcp_tool" WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id" + AND "workflow_mcp_tool"."archived_at" IS NULL )`.as('tool_count'), }) .from(workflowMcpServer) .leftJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id)) - .where(sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`) + .where( + and( + sql`${workflowMcpServer.workspaceId} IN ${workspaceIds}`, + isNull(workflowMcpServer.deletedAt), + isNull(workspace.archivedAt) + ) + ) .orderBy(workflowMcpServer.name) const baseUrl = getBaseUrl() diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 97f887e9559..e9471ce3c32 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -12,6 +12,7 @@ const { mockGenerateInternalToken, mockDbSelect, mockDbFrom, + mockDbInnerJoin, mockDbWhere, mockDbLimit, fetchMock, @@ -21,6 +22,7 @@ const { mockGenerateInternalToken: vi.fn(), mockDbSelect: vi.fn(), mockDbFrom: vi.fn(), + mockDbInnerJoin: vi.fn(), mockDbWhere: vi.fn(), mockDbLimit: vi.fn(), fetchMock: vi.fn(), @@ -29,6 +31,7 @@ const { vi.mock('drizzle-orm', () => ({ and: vi.fn(), eq: vi.fn(), + isNull: vi.fn(), })) vi.mock('@sim/db', () => ({ @@ -44,6 +47,7 @@ vi.mock('@sim/db/schema', () => ({ workspaceId: 'workspaceId', isPublic: 'isPublic', createdBy: 'createdBy', + deletedAt: 'deletedAt', }, workflowMcpTool: { serverId: 'serverId', @@ -51,10 +55,16 @@ vi.mock('@sim/db/schema', () => ({ toolDescription: 'toolDescription', parameterSchema: 'parameterSchema', workflowId: 'workflowId', + archivedAt: 'archivedAt', }, workflow: { id: 'id', isDeployed: 'isDeployed', + archivedAt: 'archivedAt', + }, + workspace: { + id: 'id', + archivedAt: 'archivedAt', }, })) @@ -88,7 +98,8 @@ describe('MCP Serve Route', () => { vi.clearAllMocks() mockDbSelect.mockReturnValue({ from: mockDbFrom }) - mockDbFrom.mockReturnValue({ where: mockDbWhere }) + mockDbFrom.mockReturnValue({ innerJoin: mockDbInnerJoin, where: mockDbWhere }) + mockDbInnerJoin.mockReturnValue({ where: mockDbWhere }) mockDbWhere.mockReturnValue({ limit: mockDbLimit }) vi.stubGlobal('fetch', fetchMock) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 99f2a83089c..5fd5fe9803c 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -15,9 +15,9 @@ import { type RequestId, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' -import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { workflow, workflowMcpServer, workflowMcpTool, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' @@ -66,7 +66,14 @@ async function getServer(serverId: string) { createdBy: workflowMcpServer.createdBy, }) .from(workflowMcpServer) - .where(eq(workflowMcpServer.id, serverId)) + .innerJoin(workspace, eq(workflowMcpServer.workspaceId, workspace.id)) + .where( + and( + eq(workflowMcpServer.id, serverId), + isNull(workflowMcpServer.deletedAt), + isNull(workspace.archivedAt) + ) + ) .limit(1) return server @@ -87,6 +94,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise { @@ -262,7 +277,13 @@ async function handleToolsCall( workflowId: workflowMcpTool.workflowId, }) .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.serverId, serverId), eq(workflowMcpTool.toolName, params.name))) + .where( + and( + eq(workflowMcpTool.serverId, serverId), + eq(workflowMcpTool.toolName, params.name), + isNull(workflowMcpTool.archivedAt) + ) + ) .limit(1) if (!tool) { return NextResponse.json( @@ -276,7 +297,7 @@ async function handleToolsCall( const [wf] = await db .select({ isDeployed: workflow.isDeployed }) .from(workflow) - .where(eq(workflow.id, tool.workflowId)) + .where(and(eq(workflow.id, tool.workflowId), isNull(workflow.archivedAt))) .limit(1) if (!wf?.isDeployed) { diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 19c2609ab5f..597244a9703 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -82,11 +82,16 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( ) } - // Only clear cache if URL changed (requires re-discovery) - const urlChanged = body.url && currentServer?.url !== body.url - if (urlChanged) { + 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 cache due to URL change`) + logger.info(`[${requestId}] Cleared MCP cache after server lifecycle update`) } logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) 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 4890dbc8f48..f5ed5371e19 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' @@ -39,7 +39,11 @@ export const GET = withMcpAuth('read')( }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) @@ -50,7 +54,7 @@ export const GET = withMcpAuth('read')( const tools = await db .select() .from(workflowMcpTool) - .where(eq(workflowMcpTool.serverId, serverId)) + .where(and(eq(workflowMcpTool.serverId, serverId), isNull(workflowMcpTool.archivedAt))) logger.info( `[${requestId}] Found workflow MCP server: ${server.name} with ${tools.length} tools` @@ -87,7 +91,11 @@ export const PATCH = withMcpAuth('write')( .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) @@ -112,7 +120,7 @@ export const PATCH = withMcpAuth('write')( const [updatedServer] = await db .update(workflowMcpServer) .set(updateData) - .where(eq(workflowMcpServer.id, serverId)) + .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) .returning() logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) 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 9a2d374ed83..f54caf4703e 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 @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' @@ -32,7 +32,11 @@ export const GET = withMcpAuth('read')( .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) @@ -43,7 +47,13 @@ export const GET = withMcpAuth('read')( const [tool] = await db .select() .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) + .where( + and( + eq(workflowMcpTool.id, toolId), + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt) + ) + ) .limit(1) if (!tool) { @@ -81,7 +91,11 @@ export const PATCH = withMcpAuth('write')( .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) @@ -92,7 +106,13 @@ export const PATCH = withMcpAuth('write')( const [existingTool] = await db .select({ id: workflowMcpTool.id }) .from(workflowMcpTool) - .where(and(eq(workflowMcpTool.id, toolId), eq(workflowMcpTool.serverId, serverId))) + .where( + and( + eq(workflowMcpTool.id, toolId), + eq(workflowMcpTool.serverId, serverId), + isNull(workflowMcpTool.archivedAt) + ) + ) .limit(1) if (!existingTool) { @@ -166,7 +186,11 @@ export const DELETE = withMcpAuth('write')( .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) 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 bdd9139f937..b0887aef1f8 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 @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' @@ -33,7 +33,11 @@ export const GET = withMcpAuth('read')( .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) @@ -56,8 +60,11 @@ export const GET = withMcpAuth('read')( isDeployed: workflow.isDeployed, }) .from(workflowMcpTool) - .leftJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id)) - .where(eq(workflowMcpTool.serverId, serverId)) + .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}`) @@ -102,7 +109,11 @@ export const POST = withMcpAuth('write')( .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) .where( - and(eq(workflowMcpServer.id, serverId), eq(workflowMcpServer.workspaceId, workspaceId)) + and( + eq(workflowMcpServer.id, serverId), + eq(workflowMcpServer.workspaceId, workspaceId), + isNull(workflowMcpServer.deletedAt) + ) ) .limit(1) @@ -119,7 +130,7 @@ export const POST = withMcpAuth('write')( workspaceId: workflow.workspaceId, }) .from(workflow) - .where(eq(workflow.id, body.workflowId)) + .where(and(eq(workflow.id, body.workflowId), isNull(workflow.archivedAt))) .limit(1) if (!workflowRecord) { @@ -157,7 +168,8 @@ export const POST = withMcpAuth('write')( .where( and( eq(workflowMcpTool.serverId, serverId), - eq(workflowMcpTool.workflowId, body.workflowId) + eq(workflowMcpTool.workflowId, body.workflowId), + isNull(workflowMcpTool.archivedAt) ) ) .limit(1) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 27515941323..185c551b271 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' @@ -37,10 +37,13 @@ export const GET = withMcpAuth('read')( SELECT COUNT(*)::int FROM "workflow_mcp_tool" WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id" + AND "workflow_mcp_tool"."archived_at" IS NULL )`.as('tool_count'), }) .from(workflowMcpServer) - .where(eq(workflowMcpServer.workspaceId, workspaceId)) + .where( + and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt)) + ) const serverIds = servers.map((s) => s.id) const tools = @@ -51,7 +54,12 @@ export const GET = withMcpAuth('read')( toolName: workflowMcpTool.toolName, }) .from(workflowMcpTool) - .where(inArray(workflowMcpTool.serverId, serverIds)) + .where( + and( + inArray(workflowMcpTool.serverId, serverIds), + isNull(workflowMcpTool.archivedAt) + ) + ) : [] const toolNamesByServer: Record = {} @@ -133,7 +141,7 @@ export const POST = withMcpAuth('write')( workspaceId: workflow.workspaceId, }) .from(workflow) - .where(inArray(workflow.id, workflowIds)) + .where(and(inArray(workflow.id, workflowIds), isNull(workflow.archivedAt))) for (const workflowRecord of workflows) { if (workflowRecord.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 2293dbd211e..20d266f13c4 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -9,14 +9,14 @@ import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload' import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/chat-streaming' import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' -import { - processContextsServer, - resolveActiveResourceContext, -} from '@/lib/copilot/process-contents' +import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents' import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + getUserEntityPermissions, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatAPI') @@ -98,6 +98,12 @@ export async function POST(req: NextRequest) { const userMessageId = providedMessageId || crypto.randomUUID() + try { + await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) + } catch { + return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 403 }) + } + let agentContexts: Array<{ type: string; content: string }> = [] if (Array.isArray(contexts) && contexts.length > 0) { try { @@ -122,7 +128,10 @@ export async function POST(req: NextRequest) { if (result.status === 'fulfilled' && result.value) { agentContexts.push(result.value) } else if (result.status === 'rejected') { - logger.error(`[${tracker.requestId}] Failed to resolve resource attachment`, result.reason) + logger.error( + `[${tracker.requestId}] Failed to resolve resource attachment`, + result.reason + ) } } } @@ -144,6 +153,10 @@ export async function POST(req: NextRequest) { conversationHistory = Array.isArray(chatResult.conversationHistory) ? chatResult.conversationHistory : [] + + if (chatId && !currentChat) { + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) + } } if (actualChatId) { diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 517669a8eaa..f9b4e1748c2 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -11,6 +11,7 @@ import { createUnauthorizedResponse, } from '@/lib/copilot/request-helpers' import { taskPubSub } from '@/lib/copilot/task-events' +import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatsAPI') @@ -30,6 +31,8 @@ export async function GET(request: NextRequest) { return createBadRequestResponse('workspaceId is required') } + await assertActiveWorkspaceAccess(workspaceId, userId) + const chats = await db .select({ id: copilotChats.id, @@ -73,6 +76,8 @@ export async function POST(request: NextRequest) { const body = await request.json() const { workspaceId } = CreateChatSchema.parse(body) + await assertActiveWorkspaceAccess(workspaceId, userId) + const now = new Date() const [chat] = await db .insert(copilotChats) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 1186a3f160e..f7f2e72d71d 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -5,7 +5,10 @@ import { checkInternalAuth } from '@/lib/auth/hybrid' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + getUserEntityPermissions, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipExecuteAPI') @@ -40,6 +43,8 @@ export async function POST(req: NextRequest) { const { messages, responseFormat, workspaceId, userId, chatId } = ExecuteRequestSchema.parse(body) + await assertActiveWorkspaceAccess(workspaceId, userId) + const effectiveChatId = chatId || crypto.randomUUID() const [workspaceContext, integrationTools, userPermission] = await Promise.all([ generateWorkspaceContext(workspaceId, userId), diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index 6a896e64845..c970e41714e 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -24,11 +24,22 @@ vi.mock('@sim/db', () => databaseMock) vi.mock('@sim/db/schema', () => ({ workflow: { id: 'id', userId: 'userId', workspaceId: 'workspaceId' }, - workflowSchedule: { id: 'id', workflowId: 'workflowId', status: 'status' }, + workflowSchedule: { + id: 'id', + workflowId: 'workflowId', + status: 'status', + cronExpression: 'cronExpression', + timezone: 'timezone', + sourceType: 'sourceType', + sourceWorkspaceId: 'sourceWorkspaceId', + archivedAt: 'archivedAt', + }, })) vi.mock('drizzle-orm', () => ({ + and: vi.fn(), eq: vi.fn(), + isNull: vi.fn(), })) vi.mock('@/lib/core/utils/request', () => requestUtilsMock) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index ba22b2e6e9a..901e91392e8 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +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' @@ -56,7 +56,7 @@ async function fetchAndAuthorize( sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, }) .from(workflowSchedule) - .where(eq(workflowSchedule.id, scheduleId)) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) .limit(1) if (!schedule) { @@ -68,8 +68,9 @@ async function fetchAndAuthorize( if (!schedule.sourceWorkspaceId) { return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 }) } - const allowed = await verifyWorkspaceMembership(userId, schedule.sourceWorkspaceId) - if (!allowed) { + const permission = await verifyWorkspaceMembership(userId, schedule.sourceWorkspaceId) + const canWrite = permission === 'admin' || permission === 'write' + if (!permission || (action === 'write' && !canWrite)) { return NextResponse.json({ error: 'Not authorized' }, { status: 403 }) } return { schedule, workspaceId: schedule.sourceWorkspaceId } @@ -135,7 +136,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ await db .update(workflowSchedule) .set({ status: 'disabled', nextRunAt: null, updatedAt: new Date() }) - .where(eq(workflowSchedule.id, scheduleId)) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`) @@ -192,7 +193,10 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } - await db.update(workflowSchedule).set(setFields).where(eq(workflowSchedule.id, scheduleId)) + await db + .update(workflowSchedule) + .set(setFields) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`) @@ -234,7 +238,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ await db .update(workflowSchedule) .set({ status: 'active', failedCount: 0, updatedAt: now, nextRunAt }) - .where(eq(workflowSchedule.id, scheduleId)) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts index 5b6d52f2d20..a80fd37beae 100644 --- a/apps/sim/app/api/schedules/execute/route.ts +++ b/apps/sim/app/api/schedules/execute/route.ts @@ -17,6 +17,7 @@ const logger = createLogger('ScheduledExecuteAPI') const dueFilter = (queuedAt: Date) => and( + isNull(workflowSchedule.archivedAt), lte(workflowSchedule.nextRunAt, queuedAt), not(eq(workflowSchedule.status, 'disabled')), ne(workflowSchedule.status, 'completed'), @@ -104,36 +105,49 @@ export async function GET(request: NextRequest) { ) if (shouldExecuteInline()) { - void (async () => { - try { - await jobQueue.startJob(jobId) - const output = await executeScheduleJob(payload) - await jobQueue.completeJob(jobId, output) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - logger.error( - `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, - { jobId, error: errorMessage } - ) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markFailedError) { - logger.error(`[${requestId}] Failed to mark job as failed`, { - jobId, - error: - markFailedError instanceof Error - ? markFailedError.message - : String(markFailedError), - }) + try { + await jobQueue.startJob(jobId) + const output = await executeScheduleJob(payload) + await jobQueue.completeJob(jobId, output) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error( + `[${requestId}] Schedule execution failed for workflow ${schedule.workflowId}`, + { + jobId, + error: errorMessage, } + ) + try { + await jobQueue.markJobFailed(jobId, errorMessage) + } catch (markFailedError) { + logger.error(`[${requestId}] Failed to mark job as failed`, { + jobId, + error: + markFailedError instanceof Error + ? markFailedError.message + : String(markFailedError), + }) } - })() + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Failed to release lock for schedule ${schedule.id} after inline execution failure` + ) + } } } catch (error) { logger.error( `[${requestId}] Failed to queue schedule execution for workflow ${schedule.workflowId}`, error ) + await releaseScheduleLock( + schedule.id, + requestId, + queuedAt, + `Failed to release lock for schedule ${schedule.id} after queue failure` + ) } }) @@ -147,21 +161,19 @@ export async function GET(request: NextRequest) { now: queueTime.toISOString(), } - void (async () => { - try { - await executeJobInline(payload) - } catch (error) { - logger.error(`[${requestId}] Job execution failed for ${job.id}`, { - error: error instanceof Error ? error.message : String(error), - }) - await releaseScheduleLock( - job.id, - requestId, - queuedAt, - `Failed to release lock for job ${job.id}` - ) - } - })() + try { + await executeJobInline(payload) + } catch (error) { + logger.error(`[${requestId}] Job execution failed for ${job.id}`, { + error: error instanceof Error ? error.message : String(error), + }) + await releaseScheduleLock( + job.id, + requestId, + queuedAt, + `Failed to release lock for job ${job.id}` + ) + } }) await Promise.allSettled([...schedulePromises, ...jobPromises]) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index 3105937a0a5..9c91530b985 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -80,6 +80,7 @@ export async function GET(req: NextRequest) { .where( and( ...conditions, + isNull(workflowSchedule.archivedAt), or( eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(workflowSchedule.deploymentVersionId)) @@ -141,7 +142,9 @@ async function handleWorkspaceSchedules(requestId: string, userId: string, works .where( and( eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), eq(workflowSchedule.triggerType, 'schedule'), + isNull(workflowSchedule.archivedAt), or(eq(workflowSchedule.sourceType, 'workflow'), isNull(workflowSchedule.sourceType)), or( eq(workflowSchedule.deploymentVersionId, workflowDeploymentVersion.id), @@ -155,7 +158,8 @@ async function handleWorkspaceSchedules(requestId: string, userId: string, works .where( and( eq(workflowSchedule.sourceWorkspaceId, workspaceId), - eq(workflowSchedule.sourceType, 'job') + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt) ) ), ]) diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 3998792993d..e9e4f5bbe5e 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { copilotChats, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' @@ -11,6 +11,7 @@ import { saveWorkflowToNormalizedTables, } from '@/lib/workflows/persistence/utils' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' +import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('SuperUserImportWorkflow') @@ -63,7 +64,7 @@ export async function POST(request: NextRequest) { const [targetWorkspace] = await db .select({ id: workspace.id, ownerId: workspace.ownerId }) .from(workspace) - .where(eq(workspace.id, targetWorkspaceId)) + .where(and(eq(workspace.id, targetWorkspaceId), isNull(workspace.archivedAt))) .limit(1) if (!targetWorkspace) { @@ -119,13 +120,18 @@ export async function POST(request: NextRequest) { // Create new workflow record const newWorkflowId = crypto.randomUUID() const now = new Date() + const dedupedName = await deduplicateWorkflowName( + `[Debug Import] ${sourceWorkflow.name}`, + targetWorkspaceId, + null + ) await db.insert(workflow).values({ id: newWorkflowId, userId: session.user.id, workspaceId: targetWorkspaceId, - folderId: null, // Don't copy folder association - name: `[Debug Import] ${sourceWorkflow.name}`, + folderId: null, + name: dedupedName, description: sourceWorkflow.description, color: sourceWorkflow.color, lastSynced: now, diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts index 6c8be053a2b..2341c9f8ad1 100644 --- a/apps/sim/app/api/table/[tableId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/route.ts @@ -144,7 +144,7 @@ export async function PATCH(request: NextRequest, { params }: TableRouteParams) } } -/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */ +/** DELETE /api/table/[tableId] - Archives a table. */ export async function DELETE(request: NextRequest, { params }: TableRouteParams) { const requestId = generateRequestId() const { tableId } = await params @@ -175,7 +175,7 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams) return NextResponse.json({ success: true, data: { - message: 'Table deleted successfully', + message: 'Table archived successfully', }, }) } catch (error) { diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index 2f9adeba3d3..18387ea80d8 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -9,6 +9,7 @@ import { listTables, TABLE_LIMITS, type TableSchema, + type TableScope, } from '@/lib/table' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { normalizeColumn } from '@/app/api/table/utils' @@ -70,6 +71,7 @@ const CreateTableSchema = z.object({ const ListTablesSchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), + scope: z.enum(['active', 'archived', 'all']).optional().default('active'), }) interface WorkspaceAccessResult { @@ -201,8 +203,9 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const workspaceId = searchParams.get('workspaceId') + const scope = searchParams.get('scope') - const validation = ListTablesSchema.safeParse({ workspaceId }) + const validation = ListTablesSchema.safeParse({ workspaceId, scope }) if (!validation.success) { return NextResponse.json( { error: 'Validation error', details: validation.error.errors }, @@ -218,7 +221,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const tables = await listTables(params.workspaceId) + const tables = await listTables(params.workspaceId, { scope: params.scope as TableScope }) logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`) diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 2ea215566e2..260b64f582b 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 { canAccessTemplate } from '@/lib/templates/permissions' import { extractRequiredCredentials, sanitizeCredentials, @@ -24,6 +25,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ 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, @@ -34,21 +41,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .where(eq(templates.id, id)) .limit(1) - if (result.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - const { template, creator } = result[0] const templateWithCreator = { ...template, creator: creator || undefined, } - if (!session?.user?.id && template.status !== 'approved') { - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - let isStarred = false if (session?.user?.id) { const { templateStars } = await import('@sim/db/schema') diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index b08d6dfb8fd..ecbbb850dd3 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -7,10 +7,13 @@ import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { canAccessTemplate, verifyTemplateOwnership } from '@/lib/templates/permissions' import { type RegenerateStateInput, regenerateWorkflowStateIds, } from '@/lib/workflows/persistence/utils' +import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TemplateUseAPI') @@ -44,11 +47,37 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) } + const workspace = await getWorkspaceById(workspaceId) + if (!workspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (permission !== 'admin' && permission !== 'write') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + logger.debug( `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}, connect: ${connectToTemplate}` ) // 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 }) + } + + 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 } + ) + } + } + const template = await db .select({ id: templates.id, @@ -61,11 +90,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .where(eq(templates.id, id)) .limit(1) - if (template.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - const templateData = template[0] // Create a new workflow ID @@ -86,14 +110,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return mapped })() - // Step 1: Create the workflow record (like imports do) + 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: - connectToTemplate && !templateData.workflowId - ? templateData.name - : `${templateData.name} (copy)`, + 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 diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 55628bfc7cf..e12cc47ad9a 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -14,11 +14,12 @@ 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 { verifyEffectiveSuperUser } from '@/lib/templates/permissions' +import { canAccessTemplate, verifyEffectiveSuperUser } from '@/lib/templates/permissions' import { extractRequiredCredentials, sanitizeCredentials, } from '@/lib/workflows/credentials/credential-extractor' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('TemplatesAPI') @@ -79,12 +80,45 @@ export async function GET(request: NextRequest) { // When fetching by workflowId, we want to get the template regardless of status // This is used by the deploy modal to check if a template exists if (params.workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: params.workflowId, + userId: session.user.id, + action: 'write', + }) + if (!authorization.allowed) { + return NextResponse.json( + { + data: [], + pagination: { + total: 0, + limit: params.limit, + offset: params.offset, + page: 1, + totalPages: 0, + }, + }, + { status: 200 } + ) + } conditions.push(eq(templates.workflowId, params.workflowId)) - // Don't apply status filter when fetching by workflowId - we want to show - // the template to its owner even if it's pending } else { // Apply status filter - only approved templates for non-super users if (params.status) { + if (!isSuperUser && params.status !== 'approved') { + return NextResponse.json( + { + data: [], + pagination: { + total: 0, + limit: params.limit, + offset: params.offset, + page: 1, + totalPages: 0, + }, + }, + { status: 200 } + ) + } conditions.push(eq(templates.status, params.status)) } else if (!isSuperUser || !params.includeAllStatuses) { // Non-super users and super users without includeAllStatuses flag see only approved templates @@ -145,16 +179,33 @@ export async function GET(request: NextRequest) { const total = totalCount[0]?.count || 0 - logger.info(`[${requestId}] Successfully retrieved ${results.length} templates`) + const visibleResults = + params.workflowId && !isSuperUser + ? ( + await Promise.all( + results.map(async (template) => { + if (template.status === 'approved') { + return template + } + const access = await canAccessTemplate(template.id, session.user.id) + return access.allowed ? template : null + }) + ) + ).filter((template): template is (typeof results)[number] => template !== null) + : results + + logger.info(`[${requestId}] Successfully retrieved ${visibleResults.length} templates`) return NextResponse.json({ - data: results, + data: visibleResults, pagination: { - total, + total: params.workflowId && !isSuperUser ? visibleResults.length : total, limit: params.limit, offset: params.offset, page: Math.floor(params.offset / params.limit) + 1, - totalPages: Math.ceil(total / params.limit), + totalPages: Math.ceil( + (params.workflowId && !isSuperUser ? visibleResults.length : total) / params.limit + ), }, }) } catch (error: any) { @@ -185,18 +236,25 @@ export async function POST(request: NextRequest) { const body = await request.json() const data = CreateTemplateSchema.parse(body) - // Verify the workflow exists and belongs to the user - const workflowExists = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, data.workflowId)) - .limit(1) + const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: data.workflowId, + userId: session.user.id, + action: 'write', + }) - if (workflowExists.length === 0) { + if (!workflowAuthorization.workflow) { logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`) return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + if (!workflowAuthorization.allowed) { + logger.warn(`[${requestId}] User denied permission to template workflow ${data.workflowId}`) + return NextResponse.json( + { error: workflowAuthorization.message || 'Access denied' }, + { status: workflowAuthorization.status || 403 } + ) + } + const { verifyCreatorPermission } = await import('@/lib/templates/permissions') const { hasPermission, error: permissionError } = await verifyCreatorPermission( session.user.id, 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 e9344b86dd2..d5a88315442 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 @@ -33,7 +33,7 @@ export async function DELETE( // 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))) + .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) .returning({ id: apiKey.id, name: apiKey.name }) if (!result.length) { 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 3eab0374dbf..9a6eeba491b 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,4 +1,4 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' @@ -8,7 +8,9 @@ import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy, } from '@/lib/webhooks/deploy' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { + activateWorkflowVersionById, deployWorkflow, loadWorkflowFromNormalizedTables, undeployWorkflow, @@ -40,11 +42,7 @@ export const POST = withAdminAuthParams(async (request, context) => const requestId = generateRequestId() try { - const [workflowRecord] = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + const workflowRecord = await getActiveWorkflowRecord(workflowId) if (!workflowRecord) { return notFoundResponse('Workflow') @@ -72,6 +70,27 @@ export const POST = withAdminAuthParams(async (request, context) => .limit(1) const previousVersionId = currentActiveVersion?.id + const rollbackDeployment = async () => { + if (previousVersionId) { + await restorePreviousVersionWebhooks({ + request, + workflow: workflowData, + userId: workflowRecord.userId, + previousVersionId, + requestId, + }) + const reactivateResult = await activateWorkflowVersionById({ + workflowId, + deploymentVersionId: previousVersionId, + }) + if (reactivateResult.success) { + return + } + } + + await undeployWorkflow({ workflowId }) + } + const deployResult = await deployWorkflow({ workflowId, deployedBy: ADMIN_ACTOR_ID, @@ -107,7 +126,7 @@ export const POST = withAdminAuthParams(async (request, context) => requestId, deploymentVersionId: deployResult.deploymentVersionId, }) - await undeployWorkflow({ workflowId }) + await rollbackDeployment() return internalErrorResponse( triggerSaveResult.error?.message || 'Failed to sync trigger configuration' ) @@ -129,16 +148,7 @@ export const POST = withAdminAuthParams(async (request, context) => requestId, deploymentVersionId: deployResult.deploymentVersionId, }) - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData, - userId: workflowRecord.userId, - previousVersionId, - requestId, - }) - } - await undeployWorkflow({ workflowId }) + await rollbackDeployment() return internalErrorResponse(scheduleResult.error || 'Failed to create schedule') } @@ -186,27 +196,23 @@ export const DELETE = withAdminAuthParams(async (request, context) const requestId = generateRequestId() try { - const [workflowRecord] = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + const workflowRecord = await getActiveWorkflowRecord(workflowId) if (!workflowRecord) { return notFoundResponse('Workflow') } + const result = await undeployWorkflow({ workflowId }) + if (!result.success) { + return internalErrorResponse(result.error || 'Failed to undeploy workflow') + } + await cleanupWebhooksForWorkflow( workflowId, workflowRecord as Record, requestId ) - const result = await undeployWorkflow({ workflowId }) - if (!result.success) { - return internalErrorResponse(result.error || 'Failed to undeploy workflow') - } - await removeMcpToolsForWorkflow(workflowId, requestId) logger.info(`Admin API: Undeployed workflow ${workflowId}`) 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 ca596d6afd8..ad8644aa49a 100644 --- a/apps/sim/app/api/v1/admin/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/[id]/route.ts @@ -13,10 +13,12 @@ */ import { db } from '@sim/db' -import { workflow, workflowBlocks, workflowEdges, workflowSchedule } from '@sim/db/schema' +import { templates, workflowBlocks, workflowEdges } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { count, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' +import { archiveWorkflow } from '@/lib/workflows/lifecycle' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, @@ -35,11 +37,7 @@ export const GET = 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) + const workflowData = await getActiveWorkflowRecord(workflowId) if (!workflowData) { return notFoundResponse('Workflow') @@ -75,24 +73,16 @@ export const DELETE = withAdminAuthParams(async (request, context) const { id: workflowId } = await context.params try { - const [workflowData] = await db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + const workflowData = await getActiveWorkflowRecord(workflowId) if (!workflowData) { return notFoundResponse('Workflow') } - await db.transaction(async (tx) => { - await Promise.all([ - tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), - tx.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), - tx.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId)), - ]) + await db.update(templates).set({ workflowId: null }).where(eq(templates.workflowId, workflowId)) - await tx.delete(workflow).where(eq(workflow.id, workflowId)) + await archiveWorkflow(workflowId, { + requestId: `admin-workflow-${workflowId}`, }) logger.info(`Admin API: Deleted workflow ${workflowId} (${workflowData.name})`) 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 a1406ca8300..1824c6508f4 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,9 +1,10 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { generateRequestId } from '@/lib/core/utils/request' import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils' import { cleanupDeploymentVersion, @@ -31,11 +32,7 @@ export const POST = withAdminAuthParams(async (request, context) => const { id: workflowId, versionId } = await context.params try { - const [workflowRecord] = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + const workflowRecord = await getActiveWorkflowRecord(workflowId) if (!workflowRecord) { return notFoundResponse('Workflow') 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 004e4c15b07..846f4c7f48f 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,6 +1,5 @@ -import { db, workflow } from '@sim/db' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -20,11 +19,7 @@ export const GET = withAdminAuthParams(async (request, context) => const { id: workflowId } = await context.params try { - const [workflowRecord] = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + const workflowRecord = await getActiveWorkflowRecord(workflowId) if (!workflowRecord) { return notFoundResponse('Workflow') 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 7c3dd58ad69..d5907ce3995 100644 --- a/apps/sim/app/api/v1/admin/workflows/import/route.ts +++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts @@ -17,10 +17,11 @@ import { db } from '@sim/db' import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { deduplicateWorkflowName } from '@/lib/workflows/utils' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -58,7 +59,7 @@ export const POST = withAdminAuth(async (request) => { const [workspaceData] = await db .select({ id: workspace.id, ownerId: workspace.ownerId }) .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .limit(1) if (!workspaceData) { @@ -93,13 +94,14 @@ export const POST = withAdminAuth(async (request) => { const workflowId = crypto.randomUUID() 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: workflowName, + name: dedupedName, description: workflowDescription, color: workflowColor, lastSynced: now, @@ -136,12 +138,12 @@ export const POST = withAdminAuth(async (request) => { } logger.info( - `Admin API: Imported workflow ${workflowId} (${workflowName}) into workspace ${workspaceId}` + `Admin API: Imported workflow ${workflowId} (${dedupedName}) into workspace ${workspaceId}` ) const response: ImportSuccessResponse = { workflowId, - name: workflowName, + name: dedupedName, success: true, } 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 6bb6a4db66c..15830d2e6dd 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 @@ -24,7 +24,7 @@ */ import { db } from '@sim/db' -import { workflow, workflowFolder, workspace } from '@sim/db/schema' +import { workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' @@ -34,6 +34,8 @@ import { parseWorkflowJson, } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -67,11 +69,7 @@ export const POST = withAdminAuthParams(async (request, context) => const rootFolderName = url.searchParams.get('rootFolderName') try { - const [workspaceData] = await db - .select({ id: workspace.id, ownerId: workspace.ownerId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceWithOwner(workspaceId) if (!workspaceData) { return notFoundResponse('Workspace') @@ -238,13 +236,14 @@ async function importSingleWorkflow( const { color: workflowColor } = extractWorkflowMetadata(parsedContent) const workflowId = crypto.randomUUID() const now = new Date() + const dedupedName = await deduplicateWorkflowName(workflowName, workspaceId, targetFolderId) await db.insert(workflow).values({ id: workflowId, userId: ownerId, workspaceId, folderId: targetFolderId, - name: workflowName, + name: dedupedName, description: workflowData.metadata?.description || 'Imported via Admin API', color: workflowColor, lastSynced: now, @@ -261,7 +260,7 @@ async function importSingleWorkflow( await db.delete(workflow).where(eq(workflow.id, workflowId)) return { workflowId: '', - name: workflowName, + name: dedupedName, success: false, error: `Failed to save state: ${saveResult.error}`, } @@ -287,7 +286,7 @@ async function importSingleWorkflow( return { workflowId, - name: workflowName, + name: dedupedName, success: true, } } catch (error) { 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 30afdda571d..07da5734245 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 @@ -22,10 +22,11 @@ */ import { db } from '@sim/db' -import { permissions, user, workspace } from '@sim/db/schema' +import { permissions, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { revokeWorkspaceCredentialMemberships } from '@/lib/credentials/access' +import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -46,11 +47,7 @@ export const GET = withAdminAuthParams(async (_, context) => { const { id: workspaceId, memberId } = await context.params try { - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { return notFoundResponse('Workspace') @@ -113,11 +110,7 @@ export const PATCH = withAdminAuthParams(async (request, context) = return badRequestResponse('permissions must be "admin", "write", or "read"') } - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { return notFoundResponse('Workspace') @@ -185,11 +178,7 @@ export const DELETE = withAdminAuthParams(async (_, context) => { const { id: workspaceId, memberId } = await context.params try { - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { return notFoundResponse('Workspace') 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 78298feb490..78b70b7d52e 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 @@ -32,10 +32,11 @@ import crypto from 'crypto' import { db } from '@sim/db' -import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema' +import { permissions, user, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq } from 'drizzle-orm' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' +import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -62,11 +63,7 @@ export const GET = withAdminAuthParams(async (request, context) => const { limit, offset } = parsePaginationParams(url) try { - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { return notFoundResponse('Workspace') @@ -134,11 +131,7 @@ export const POST = withAdminAuthParams(async (request, context) => return badRequestResponse('permissions must be "admin", "write", or "read"') } - const [workspaceData] = await db - .select({ id: workspace.id, name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { return notFoundResponse('Workspace') @@ -275,11 +268,7 @@ export const DELETE = withAdminAuthParams(async (request, context) return badRequestResponse('userId query parameter is required') } - const [workspaceData] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) + const workspaceData = await getWorkspaceById(workspaceId) if (!workspaceData) { return notFoundResponse('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 ea1ab87fc50..896af40d6a7 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 @@ -17,16 +17,11 @@ */ import { db } from '@sim/db' -import { - workflow, - workflowBlocks, - workflowEdges, - workflowSchedule, - workspace, -} from '@sim/db/schema' +import { workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { count, eq, inArray } from 'drizzle-orm' +import { and, count, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, listResponse, notFoundResponse } from '@/app/api/v1/admin/responses' import { @@ -51,7 +46,7 @@ export const GET = withAdminAuthParams(async (request, context) => const [workspaceData] = await db .select({ id: workspace.id }) .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .limit(1) if (!workspaceData) { @@ -59,11 +54,14 @@ export const GET = withAdminAuthParams(async (request, context) => } const [countResult, workflows] = await Promise.all([ - db.select({ total: count() }).from(workflow).where(eq(workflow.workspaceId, workspaceId)), + db + .select({ total: count() }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), db .select() .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) .orderBy(workflow.name) .limit(limit) .offset(offset), @@ -91,7 +89,7 @@ export const DELETE = withAdminAuthParams(async (request, context) const [workspaceData] = await db .select({ id: workspace.id }) .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .limit(1) if (!workspaceData) { @@ -101,27 +99,19 @@ export const DELETE = withAdminAuthParams(async (request, context) const workflowsToDelete = await db .select({ id: workflow.id }) .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) if (workflowsToDelete.length === 0) { return NextResponse.json({ success: true, deleted: 0 }) } - const workflowIds = workflowsToDelete.map((w) => w.id) - - await db.transaction(async (tx) => { - await Promise.all([ - tx.delete(workflowBlocks).where(inArray(workflowBlocks.workflowId, workflowIds)), - tx.delete(workflowEdges).where(inArray(workflowEdges.workflowId, workflowIds)), - tx.delete(workflowSchedule).where(inArray(workflowSchedule.workflowId, workflowIds)), - ]) - - await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId)) + const deletedCount = await archiveWorkflowsForWorkspace(workspaceId, { + requestId: `admin-workspace-${workspaceId}`, }) - logger.info(`Admin API: Deleted ${workflowIds.length} workflows from workspace ${workspaceId}`) + logger.info(`Admin API: Deleted ${deletedCount} workflows from workspace ${workspaceId}`) - return NextResponse.json({ success: true, deleted: workflowIds.length }) + 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/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index bef87d59eee..e3acb50a10a 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' -import { resolveWorkflowIdForUser } from '@/lib/workflows/utils' +import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { authenticateV1Request } from '@/app/api/v1/auth' const logger = createLogger('CopilotHeadlessAPI') @@ -47,7 +47,8 @@ export async function POST(req: NextRequest) { const resolved = await resolveWorkflowIdForUser( auth.userId, parsed.workflowId, - parsed.workflowName + parsed.workflowName, + auth.keyType === 'workspace' ? auth.workspaceId : undefined ) if (!resolved) { return NextResponse.json( @@ -59,6 +60,16 @@ export async function POST(req: NextRequest) { ) } + if (auth.keyType === 'workspace' && auth.workspaceId) { + const workflow = await getWorkflowById(resolved.workflowId) + if (!workflow?.workspaceId || workflow.workspaceId !== auth.workspaceId) { + return NextResponse.json( + { success: false, error: 'API key is not authorized for this workspace' }, + { status: 403 } + ) + } + } + // Transform mode to transport mode (same as client API) // build and agent both map to 'agent' on the backend const effectiveMode = parsed.mode === 'agent' ? 'build' : parsed.mode diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 8d567f644eb..7007053681b 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -89,7 +89,7 @@ export async function GET(request: NextRequest, { params }: FileRouteParams) { } } -/** DELETE /api/v1/files/[fileId] — Delete a file. */ +/** DELETE /api/v1/files/[fileId] — Archive a file. */ export async function DELETE(request: NextRequest, { params }: FileRouteParams) { const requestId = generateRequestId() @@ -131,7 +131,7 @@ export async function DELETE(request: NextRequest, { params }: FileRouteParams) await deleteWorkspaceFile(workspaceId, fileId) logger.info( - `[${requestId}] Deleted file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` + `[${requestId}] Archived file: ${fileRecord.name} (${fileId}) from workspace ${workspaceId}` ) recordAudit({ @@ -141,14 +141,14 @@ export async function DELETE(request: NextRequest, { params }: FileRouteParams) resourceType: AuditResourceType.FILE, resourceId: fileId, resourceName: fileRecord.name, - description: `Deleted file "${fileRecord.name}" via API`, + description: `Archived file "${fileRecord.name}" via API`, request, }) return NextResponse.json({ success: true, data: { - message: 'File deleted successfully', + message: 'File archived successfully', }, }) } catch (error) { 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 221c76d1317..b69721329a4 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 @@ -73,6 +73,8 @@ export async function GET(request: NextRequest, { params }: DocumentDetailRouteP and( eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -144,6 +146,8 @@ export async function DELETE(request: NextRequest, { params }: DocumentDetailRou and( eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) diff --git a/apps/sim/app/api/v1/logs/[id]/route.ts b/apps/sim/app/api/v1/logs/[id]/route.ts index b1d8f89ff36..6e5176c5760 100644 --- a/apps/sim/app/api/v1/logs/[id]/route.ts +++ b/apps/sim/app/api/v1/logs/[id]/route.ts @@ -47,12 +47,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ workflowUpdatedAt: workflow.updatedAt, }) .from(workflowExecutionLogs) - .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin( permissions, and( eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.userId, userId) ) ) @@ -66,7 +66,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const workflowSummary = { id: log.workflowId, - name: log.workflowName, + name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, color: log.workflowColor, folderId: log.workflowFolderId, @@ -74,6 +74,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ workspaceId: log.workflowWorkspaceId, createdAt: log.workflowCreatedAt, updatedAt: log.workflowUpdatedAt, + deleted: !log.workflowName, } const response = { 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 5c2967ef735..f791c13b25f 100644 --- a/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts +++ b/apps/sim/app/api/v1/logs/executions/[executionId]/route.ts @@ -1,10 +1,5 @@ import { db } from '@sim/db' -import { - permissions, - workflow, - workflowExecutionLogs, - workflowExecutionSnapshots, -} from '@sim/db/schema' +import { permissions, workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -31,15 +26,13 @@ export async function GET( const rows = await db .select({ log: workflowExecutionLogs, - workflow: workflow, }) .from(workflowExecutionLogs) - .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin( permissions, and( eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.userId, userId) ) ) diff --git a/apps/sim/app/api/v1/logs/route.ts b/apps/sim/app/api/v1/logs/route.ts index 83a7b621923..bc9562fd273 100644 --- a/apps/sim/app/api/v1/logs/route.ts +++ b/apps/sim/app/api/v1/logs/route.ts @@ -123,12 +123,12 @@ export async function GET(request: NextRequest) { workflowDescription: workflow.description, }) .from(workflowExecutionLogs) - .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) .innerJoin( permissions, and( eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, params.workspaceId), + eq(permissions.entityId, workflowExecutionLogs.workspaceId), eq(permissions.userId, userId) ) ) @@ -168,8 +168,9 @@ export async function GET(request: NextRequest) { if (params.details === 'full') { result.workflow = { id: log.workflowId, - name: log.workflowName, + name: log.workflowName || 'Deleted Workflow', description: log.workflowDescription, + deleted: !log.workflowName, } if (log.cost) { diff --git a/apps/sim/app/api/v1/tables/[tableId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/route.ts index a4b1048ffb8..06c2a1de4fb 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/route.ts @@ -84,7 +84,7 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) { } } -/** DELETE /api/v1/tables/[tableId] — Delete a table. */ +/** DELETE /api/v1/tables/[tableId] — Archive a table. */ export async function DELETE(request: NextRequest, { params }: TableRouteParams) { const requestId = generateRequestId() @@ -125,14 +125,14 @@ export async function DELETE(request: NextRequest, { params }: TableRouteParams) resourceType: AuditResourceType.TABLE, resourceId: tableId, resourceName: result.table.name, - description: `Deleted table "${result.table.name}"`, + description: `Archived table "${result.table.name}"`, request, }) return NextResponse.json({ success: true, data: { - message: 'Table deleted successfully', + message: 'Table archived successfully', }, }) } catch (error) { diff --git a/apps/sim/app/api/v1/workflows/[id]/route.ts b/apps/sim/app/api/v1/workflows/[id]/route.ts index 658a0f8ea4d..4e1360ea75f 100644 --- a/apps/sim/app/api/v1/workflows/[id]/route.ts +++ b/apps/sim/app/api/v1/workflows/[id]/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { permissions, workflow, workflowBlocks } from '@sim/db/schema' +import { workflowBlocks } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -25,39 +26,20 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId }) - const rows = await db - .select({ - id: workflow.id, - name: workflow.name, - description: workflow.description, - color: workflow.color, - folderId: workflow.folderId, - workspaceId: workflow.workspaceId, - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, - runCount: workflow.runCount, - lastRunAt: workflow.lastRunAt, - variables: workflow.variables, - createdAt: workflow.createdAt, - updatedAt: workflow.updatedAt, - }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, id)) - .limit(1) - - const workflowData = rows[0] + 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 blockRows = await db .select({ id: workflowBlocks.id, diff --git a/apps/sim/app/api/v1/workflows/route.ts b/apps/sim/app/api/v1/workflows/route.ts index 23bb707f152..267650aff10 100644 --- a/apps/sim/app/api/v1/workflows/route.ts +++ b/apps/sim/app/api/v1/workflows/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { permissions, workflow } from '@sim/db/schema' +import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, gt, or } from 'drizzle-orm' +import { and, asc, eq, gt, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -69,12 +70,12 @@ export async function GET(request: NextRequest) { }, }) - const conditions = [ - eq(workflow.workspaceId, params.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, params.workspaceId), - eq(permissions.userId, userId), - ] + const permission = await getUserEntityPermissions(userId, 'workspace', params.workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const conditions = [eq(workflow.workspaceId, params.workspaceId), isNull(workflow.archivedAt)] if (params.folderId) { conditions.push(eq(workflow.folderId, params.folderId)) @@ -124,14 +125,6 @@ export async function GET(request: NextRequest) { updatedAt: workflow.updatedAt, }) .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, params.workspaceId), - eq(permissions.userId, userId) - ) - ) .where(and(...conditions)) .orderBy(...orderByClause) .limit(params.limit + 1) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index f1f1fbd628a..88d8f26e0b3 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' @@ -41,7 +41,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(eq(webhook.id, id)) + .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) .limit(1) if (webhooks.length === 0) { @@ -106,7 +106,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(eq(webhook.id, id)) + .where(and(eq(webhook.id, id), isNull(webhook.archivedAt))) .limit(1) if (webhooks.length === 0) { @@ -204,7 +204,13 @@ export async function DELETE( const allCredentialSetWebhooks = await db .select() .from(webhook) - .where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId))) + .where( + and( + eq(webhook.workflowId, webhookData.workflow.id), + eq(webhook.blockId, blockId), + isNull(webhook.archivedAt) + ) + ) const webhooksToDelete = allCredentialSetWebhooks.filter( (w) => w.credentialSetId === credentialSetId diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 4d5508a1256..dfec618cac4 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -13,6 +13,7 @@ import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import { cleanupExternalWebhook, createExternalWebhookSubscription, + shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' import { mergeNonUserFields } from '@/lib/webhooks/utils' import { @@ -28,6 +29,35 @@ const logger = createLogger('WebhooksAPI') export const dynamic = 'force-dynamic' +async function revertSavedWebhook( + savedWebhook: any, + existingWebhook: any, + requestId: string +): Promise { + if (existingWebhook) { + await db + .update(webhook) + .set({ + workflowId: existingWebhook.workflowId, + blockId: existingWebhook.blockId, + path: existingWebhook.path, + provider: existingWebhook.provider, + providerConfig: existingWebhook.providerConfig, + credentialSetId: existingWebhook.credentialSetId, + isActive: existingWebhook.isActive, + archivedAt: existingWebhook.archivedAt, + updatedAt: existingWebhook.updatedAt, + }) + .where(eq(webhook.id, savedWebhook.id)) + logger.info(`[${requestId}] Restored previous webhook configuration after failed re-save`, { + webhookId: savedWebhook.id, + }) + return + } + + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) +} + // Get all webhooks for the current user export async function GET(request: NextRequest) { const requestId = generateRequestId() @@ -93,6 +123,7 @@ export async function GET(request: NextRequest) { and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), + isNull(webhook.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) @@ -132,7 +163,7 @@ export async function GET(request: NextRequest) { }) .from(webhook) .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(inArray(workflow.workspaceId, workspaceIds)) + .where(and(inArray(workflow.workspaceId, workspaceIds), isNull(webhook.archivedAt))) logger.info(`[${requestId}] Retrieved ${webhooks.length} workspace-accessible webhooks`) return NextResponse.json({ webhooks }, { status: 200 }) @@ -196,6 +227,7 @@ export async function POST(request: NextRequest) { and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), + isNull(webhook.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) @@ -275,6 +307,7 @@ export async function POST(request: NextRequest) { and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), + isNull(webhook.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) @@ -290,7 +323,7 @@ export async function POST(request: NextRequest) { const existingByPath = await db .select({ id: webhook.id, workflowId: webhook.workflowId }) .from(webhook) - .where(eq(webhook.path, finalPath)) + .where(and(eq(webhook.path, finalPath), isNull(webhook.archivedAt))) .limit(1) if (existingByPath.length > 0) { // If a webhook with the same path exists but belongs to a different workflow, return an error @@ -306,6 +339,7 @@ export async function POST(request: NextRequest) { } let savedWebhook: any = null + let existingWebhook: any = null const originalProviderConfig = providerConfig || {} let resolvedProviderConfig = await resolveEnvVarsInObject( originalProviderConfig, @@ -382,7 +416,7 @@ export async function POST(request: NextRequest) { const webhookRows = await db .select() .from(webhook) - .where(eq(webhook.id, wh.id)) + .where(and(eq(webhook.id, wh.id), isNull(webhook.archivedAt))) .limit(1) if (webhookRows.length > 0) { @@ -427,7 +461,7 @@ export async function POST(request: NextRequest) { const primaryWebhookRows = await db .select() .from(webhook) - .where(eq(webhook.id, syncResult.webhooks[0].id)) + .where(and(eq(webhook.id, syncResult.webhooks[0].id), isNull(webhook.archivedAt))) .limit(1) return NextResponse.json( @@ -468,26 +502,53 @@ export async function POST(request: NextRequest) { const userProvided = originalProviderConfig as Record const configToSave: Record = { ...userProvided } - try { - const result = await createExternalWebhookSubscription( - request, - createTempWebhookData(), - workflowRecord, - userId, - requestId - ) - const updatedConfig = result.updatedProviderConfig as Record - mergeNonUserFields(configToSave, updatedConfig, userProvided) - resolvedProviderConfig = updatedConfig - externalSubscriptionCreated = result.externalSubscriptionCreated - } catch (err) { - logger.error(`[${requestId}] Error creating external webhook subscription`, err) - return NextResponse.json( - { - error: 'Failed to create external webhook subscription', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } + if (targetWebhookId) { + const existingRows = await db + .select() + .from(webhook) + .where(eq(webhook.id, targetWebhookId)) + .limit(1) + existingWebhook = existingRows[0] || null + } + + const shouldRecreateSubscription = + existingWebhook && + shouldRecreateExternalWebhookSubscription({ + previousProvider: existingWebhook.provider as string, + nextProvider: provider, + previousConfig: ((existingWebhook.providerConfig as Record) || + {}) as Record, + nextConfig: resolvedProviderConfig, + }) + + if (!existingWebhook || shouldRecreateSubscription) { + try { + const result = await createExternalWebhookSubscription( + request, + createTempWebhookData(), + workflowRecord, + userId, + requestId + ) + const updatedConfig = result.updatedProviderConfig as Record + mergeNonUserFields(configToSave, updatedConfig, userProvided) + resolvedProviderConfig = updatedConfig + externalSubscriptionCreated = result.externalSubscriptionCreated + } catch (err) { + logger.error(`[${requestId}] Error creating external webhook subscription`, err) + return NextResponse.json( + { + error: 'Failed to create external webhook subscription', + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } + } else { + mergeNonUserFields( + configToSave, + (existingWebhook.providerConfig as Record) || {}, + userProvided ) } @@ -558,6 +619,17 @@ export async function POST(request: NextRequest) { throw dbError } + if (existingWebhook && shouldRecreateSubscription) { + try { + await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId) + } catch (cleanupError) { + logger.warn( + `[${requestId}] Failed to cleanup previous external webhook subscription ${existingWebhook.id}`, + cleanupError + ) + } + } + // --- Gmail/Outlook webhook setup (these don't require external subscriptions, configure after DB save) --- if (savedWebhook && provider === 'gmail') { logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) @@ -566,7 +638,7 @@ export async function POST(request: NextRequest) { if (!success) { logger.error(`[${requestId}] Failed to configure Gmail polling, rolling back webhook`) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { error: 'Failed to configure Gmail polling', @@ -582,7 +654,7 @@ export async function POST(request: NextRequest) { `[${requestId}] Error setting up Gmail webhook configuration, rolling back webhook`, err ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { error: 'Failed to configure Gmail webhook', @@ -604,7 +676,7 @@ export async function POST(request: NextRequest) { if (!success) { logger.error(`[${requestId}] Failed to configure Outlook polling, rolling back webhook`) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { error: 'Failed to configure Outlook polling', @@ -620,7 +692,7 @@ export async function POST(request: NextRequest) { `[${requestId}] Error setting up Outlook webhook configuration, rolling back webhook`, err ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { error: 'Failed to configure Outlook webhook', @@ -640,7 +712,7 @@ export async function POST(request: NextRequest) { if (!success) { logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { error: 'Failed to configure RSS polling', @@ -656,7 +728,7 @@ export async function POST(request: NextRequest) { `[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`, err ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { error: 'Failed to configure RSS webhook', diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts index 3456e372e8f..88b6fc16804 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts @@ -23,7 +23,9 @@ const { })) vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), eq: vi.fn(), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), })) vi.mock('@sim/db', () => ({ @@ -45,6 +47,7 @@ vi.mock('@sim/db/schema', () => ({ password: 'password', isActive: 'isActive', workflowId: 'workflowId', + archivedAt: 'archivedAt', }, })) 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 ef84667d5d3..22d9c7d5532 100644 --- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +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' @@ -50,7 +50,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ isActive: chat.isActive, }) .from(chat) - .where(eq(chat.workflowId, id)) + .where(and(eq(chat.workflowId, id), isNull(chat.archivedAt))) .limit(1) const isDeployed = deploymentResults.length > 0 && deploymentResults[0].isActive diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 1dd8798a3f2..5ad26782382 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -11,6 +11,7 @@ import { saveTriggerWebhooksForDeploy, } from '@/lib/webhooks/deploy' import { + activateWorkflowVersionById, deployWorkflow, loadWorkflowFromNormalizedTables, undeployWorkflow, @@ -154,6 +155,27 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .limit(1) const previousVersionId = currentActiveVersion?.id + const rollbackDeployment = async () => { + if (previousVersionId) { + await restorePreviousVersionWebhooks({ + request, + workflow: workflowData as Record, + userId: actorUserId, + previousVersionId, + requestId, + }) + const reactivateResult = await activateWorkflowVersionById({ + workflowId: id, + deploymentVersionId: previousVersionId, + }) + if (reactivateResult.success) { + return + } + } + + await undeployWorkflow({ workflowId: id }) + } + const deployResult = await deployWorkflow({ workflowId: id, deployedBy: actorUserId, @@ -190,7 +212,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ requestId, deploymentVersionId, }) - await undeployWorkflow({ workflowId: id }) + await rollbackDeployment() return createErrorResponse( triggerSaveResult.error?.message || 'Failed to save trigger configuration', triggerSaveResult.error?.status || 500 @@ -214,16 +236,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ requestId, deploymentVersionId, }) - if (previousVersionId) { - await restorePreviousVersionWebhooks({ - request, - workflow: workflowData as Record, - userId: actorUserId, - previousVersionId, - requestId, - }) - } - await undeployWorkflow({ workflowId: id }) + await rollbackDeployment() return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500) } if (scheduleResult.scheduleId) { @@ -364,14 +377,13 @@ export async function DELETE( return createErrorResponse(error.message, error.status) } - // Clean up external webhook subscriptions before undeploying - await cleanupWebhooksForWorkflow(id, workflowData as Record, requestId) - const result = await undeployWorkflow({ workflowId: id }) if (!result.success) { return createErrorResponse(result.error || 'Failed to undeploy workflow', 500) } + await cleanupWebhooksForWorkflow(id, workflowData as Record, requestId) + await removeMcpToolsForWorkflow(id, requestId) logger.info(`[${requestId}] Workflow undeployed successfully: ${id}`) 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 6050bb4b253..d3762c9181f 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -5,7 +5,6 @@ import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' -import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -91,13 +90,6 @@ export async function POST( .set({ lastSynced: new Date(), updatedAt: new Date() }) .where(eq(workflow.id, id)) - await syncMcpToolsForWorkflow({ - workflowId: id, - requestId, - state: deployedState, - context: 'revert', - }) - try { const socketServerUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' await fetch(`${socketServerUrl}/api/workflow-reverted`, { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 5207f77c019..be66dca6a12 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -339,11 +339,55 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: runFromBlock: rawRunFromBlock, } = validation.data + if (isPublicApiAccess && isClientSession) { + return NextResponse.json( + { error: 'Public API callers cannot set isClientSession' }, + { status: 400 } + ) + } + + if (auth.authType === 'api_key') { + if (isClientSession) { + return NextResponse.json( + { error: 'API key callers cannot set isClientSession' }, + { status: 400 } + ) + } + + if (workflowStateOverride) { + return NextResponse.json( + { error: 'API key callers cannot provide workflowStateOverride' }, + { status: 400 } + ) + } + + if (useDraftState) { + return NextResponse.json( + { error: 'API key callers cannot execute draft workflow state' }, + { status: 400 } + ) + } + } + // Resolve runFromBlock snapshot from executionId if needed let resolvedRunFromBlock: | { startBlockId: string; sourceSnapshot: SerializableExecutionState } | undefined if (rawRunFromBlock) { + if (rawRunFromBlock.sourceSnapshot && auth.authType === 'api_key') { + return NextResponse.json( + { error: 'API key callers cannot provide runFromBlock.sourceSnapshot' }, + { status: 400 } + ) + } + + if (rawRunFromBlock.executionId && (auth.authType === 'api_key' || isPublicApiAccess)) { + return NextResponse.json( + { error: 'External callers cannot resume from stored execution snapshots' }, + { status: 400 } + ) + } + if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) { // Public API callers cannot inject arbitrary block state via sourceSnapshot. // They must use executionId to resume from a server-stored execution state. @@ -352,13 +396,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState, } } else if (rawRunFromBlock.executionId) { - const { getExecutionState, getLatestExecutionState } = await import( + const { getExecutionStateForWorkflow, getLatestExecutionState } = await import( '@/lib/workflows/executor/execution-state' ) const snapshot = rawRunFromBlock.executionId === 'latest' ? await getLatestExecutionState(workflowId) - : await getExecutionState(rawRunFromBlock.executionId) + : await getExecutionStateForWorkflow(rawRunFromBlock.executionId, workflowId) if (!snapshot) { return NextResponse.json( { @@ -413,6 +457,25 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const enableSSE = streamHeader || streamParam === true const executionModeHeader = req.headers.get('X-Execution-Mode') const isAsyncMode = executionModeHeader === 'async' + const requiresWriteExecutionAccess = Boolean( + useDraftState || workflowStateOverride || rawRunFromBlock + ) + + if ( + isAsyncMode && + (useDraftState !== undefined || + workflowStateOverride !== undefined || + rawRunFromBlock !== undefined || + stopAfterBlockId !== undefined || + selectedOutputs?.length || + includeFileBase64 !== undefined || + base64MaxBytes !== undefined) + ) { + return NextResponse.json( + { error: 'Async execution does not support draft or override execution controls' }, + { status: 400 } + ) + } logger.info(`[${requestId}] Starting server-side execution`, { workflowId, @@ -448,7 +511,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ workflowId, userId, - action: shouldUseDraftState ? 'write' : 'read', + action: requiresWriteExecutionAccess ? 'write' : 'read', }) if (!workflowAuthorization.allowed) { return NextResponse.json( @@ -487,6 +550,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } const workspaceId = workflow.workspaceId + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workspaceId) { + return NextResponse.json( + { error: 'API key is not authorized for this workspace' }, + { status: 403 } + ) + } + logger.info(`[${requestId}] Preprocessing passed`, { workflowId, actorUserId, 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 49c99e1ede6..842e130eaaa 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 @@ -33,6 +33,16 @@ export async function POST( ) } + 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 }) const marked = await markExecutionCancelled(executionId) 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 88e3c874470..bd52d0f0a07 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 @@ -47,6 +47,16 @@ export async function GET( ) } + if ( + auth.apiKeyType === 'workspace' && + workflowAuthorization.workflow?.workspaceId !== auth.workspaceId + ) { + return NextResponse.json( + { error: 'API key is not authorized for this workspace' }, + { status: 403 } + ) + } + const meta = await getExecutionMeta(executionId) if (!meta) { return NextResponse.json({ error: 'Execution buffer not found or expired' }, { status: 404 }) diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index fba05c92c9c..916416b685e 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -21,7 +21,7 @@ const mockCheckSessionOrInternalAuth = vi.fn() const mockLoadWorkflowFromNormalizedTables = vi.fn() const mockGetWorkflowById = vi.fn() const mockAuthorizeWorkflowByWorkspacePermission = vi.fn() -const mockDbDelete = vi.fn() +const mockArchiveWorkflow = vi.fn() const mockDbUpdate = vi.fn() const mockDbSelect = vi.fn() @@ -71,9 +71,12 @@ vi.mock('@/lib/workflows/utils', () => ({ }) => mockAuthorizeWorkflowByWorkspacePermission(params), })) +vi.mock('@/lib/workflows/lifecycle', () => ({ + archiveWorkflow: (...args: unknown[]) => mockArchiveWorkflow(...args), +})) + vi.mock('@sim/db', () => ({ db: { - delete: () => mockDbDelete(), update: () => mockDbUpdate(), select: () => mockDbSelect(), }, @@ -296,8 +299,9 @@ describe('Workflow By ID API Route', () => { }), }) - mockDbDelete.mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), + mockArchiveWorkflow.mockResolvedValue({ + archived: true, + workflow: mockWorkflow, }) setupGlobalFetchMock({ ok: true }) @@ -339,8 +343,9 @@ describe('Workflow By ID API Route', () => { }), }) - mockDbDelete.mockReturnValue({ - where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]), + mockArchiveWorkflow.mockResolvedValue({ + archived: true, + workflow: mockWorkflow, }) setupGlobalFetchMock({ ok: true }) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 140cc8ef53f..e9a8428b438 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -1,14 +1,13 @@ import { db } from '@sim/db' -import { templates, webhook, workflow } from '@sim/db/schema' +import { templates, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { env } from '@/lib/core/config/env' -import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' +import { archiveWorkflow } from '@/lib/workflows/lifecycle' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils' @@ -49,6 +48,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflowData.workspaceId) { + return NextResponse.json( + { error: 'API key is not authorized for this workspace' }, + { status: 403 } + ) + } + 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. @@ -183,7 +189,7 @@ export async function DELETE( const totalWorkflowsInWorkspace = await db .select({ id: workflow.id }) .from(workflow) - .where(eq(workflow.workspaceId, workflowData.workspaceId)) + .where(and(eq(workflow.workspaceId, workflowData.workspaceId), isNull(workflow.archivedAt))) if (totalWorkflowsInWorkspace.length <= 1) { return NextResponse.json( @@ -241,92 +247,13 @@ export async function DELETE( } } - // Clean up external webhooks before deleting workflow - try { - const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions') - const webhooksToCleanup = 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.workflowId, workflowId)) - - if (webhooksToCleanup.length > 0) { - logger.info( - `[${requestId}] Found ${webhooksToCleanup.length} webhook(s) to cleanup for workflow ${workflowId}` - ) - - // Clean up each webhook (don't fail if cleanup fails) - for (const webhookData of webhooksToCleanup) { - try { - await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId) - } catch (cleanupError) { - logger.warn( - `[${requestId}] Failed to cleanup external webhook ${webhookData.webhook.id} during workflow deletion`, - cleanupError - ) - // Continue with deletion even if cleanup fails - } - } - } - } catch (webhookCleanupError) { - logger.warn( - `[${requestId}] Error during webhook cleanup for workflow deletion (continuing with deletion)`, - webhookCleanupError - ) - // Continue with workflow deletion even if webhook cleanup fails - } - - await db.delete(workflow).where(eq(workflow.id, workflowId)) - - try { - PlatformEvents.workflowDeleted({ - workflowId, - workspaceId: workflowData.workspaceId || undefined, - }) - } catch { - // Telemetry should not fail the operation + const archiveResult = await archiveWorkflow(workflowId, { requestId }) + if (!archiveResult.workflow) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) - - // Notify Socket.IO system to disconnect users from this workflow's room - // This prevents "Block not found" errors when collaborative updates try to process - // after the workflow has been deleted - try { - const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' - const socketResponse = await fetch(`${socketUrl}/api/workflow-deleted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }) - - if (socketResponse.ok) { - logger.info( - `[${requestId}] Notified Socket.IO server about workflow ${workflowId} deletion` - ) - } else { - logger.warn( - `[${requestId}] Failed to notify Socket.IO server about workflow ${workflowId} deletion` - ) - } - } catch (error) { - logger.warn( - `[${requestId}] Error notifying Socket.IO server about workflow ${workflowId} deletion:`, - error - ) - // Don't fail the deletion if Socket.IO notification fails - } + logger.info(`[${requestId}] Successfully archived workflow ${workflowId} in ${elapsed}ms`) recordAudit({ workspaceId: workflowData.workspaceId || null, @@ -337,8 +264,9 @@ export async function DELETE( resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, resourceName: workflowData.name, - description: `Deleted workflow "${workflowData.name}"`, + description: `Archived workflow "${workflowData.name}"`, metadata: { + archived: archiveResult.archived, deleteTemplates: deleteTemplatesParam === 'delete', }, request, @@ -417,6 +345,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const conditions = [ eq(workflow.workspaceId, workflowData.workspaceId), + isNull(workflow.archivedAt), eq(workflow.name, targetName), ne(workflow.id, workflowId), ] diff --git a/apps/sim/app/api/workflows/route.test.ts b/apps/sim/app/api/workflows/route.test.ts index e1b83bdb0eb..fbbc652a857 100644 --- a/apps/sim/app/api/workflows/route.test.ts +++ b/apps/sim/app/api/workflows/route.test.ts @@ -45,6 +45,8 @@ vi.mock('@sim/db/schema', () => ({ id: 'id', folderId: 'folderId', userId: 'userId', + name: 'name', + archivedAt: 'archivedAt', updatedAt: 'updatedAt', workspaceId: 'workspaceId', sortOrder: 'sortOrder', @@ -107,11 +109,16 @@ describe('Workflows API Route - POST ordering', () => { const minResultsQueue: Array> = [ [{ minOrder: 5 }], [{ minOrder: 2 }], + [], ] mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), + where: vi.fn().mockImplementation(() => ({ + limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), + then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) => + Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled), + })), }), })) @@ -140,11 +147,15 @@ describe('Workflows API Route - POST ordering', () => { }) it('defaults to sortOrder 0 when there are no siblings', async () => { - const minResultsQueue: Array> = [[], []] + const minResultsQueue: Array> = [[], [], []] mockDbSelect.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), + where: vi.fn().mockImplementation(() => ({ + limit: vi.fn().mockImplementation(() => Promise.resolve(minResultsQueue.shift() ?? [])), + then: (onFulfilled: (value: Array<{ minOrder: number }>) => unknown) => + Promise.resolve(minResultsQueue.shift() ?? []).then(onFulfilled), + })), }), })) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 4129f35b8ab..3181185b75e 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -1,13 +1,14 @@ import { db } from '@sim/db' import { permissions, workflow, workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, min, sql } from 'drizzle-orm' 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 { generateRequestId } from '@/lib/core/utils/request' import { getNextWorkflowColor } from '@/lib/workflows/colors' +import { listWorkflows, type WorkflowScope } from '@/lib/workflows/utils' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -32,6 +33,7 @@ export async function GET(request: NextRequest) { const startTime = Date.now() const url = new URL(request.url) const workspaceId = url.searchParams.get('workspaceId') + const scope = (url.searchParams.get('scope') ?? 'active') as WorkflowScope try { const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) @@ -67,16 +69,16 @@ export async function GET(request: NextRequest) { } } + if (!['active', 'archived', 'all'].includes(scope)) { + return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + } + let workflows const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)] if (workspaceId) { - workflows = await db - .select() - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - .orderBy(...orderByClause) + workflows = await listWorkflows(workspaceId, { scope }) } else { const workspacePermissionRows = await db .select({ workspaceId: permissions.entityId }) @@ -89,7 +91,16 @@ export async function GET(request: NextRequest) { workflows = await db .select() .from(workflow) - .where(inArray(workflow.workspaceId, workspaceIds)) + .where( + scope === 'all' + ? inArray(workflow.workspaceId, workspaceIds) + : scope === 'archived' + ? and( + inArray(workflow.workspaceId, workspaceIds), + sql`${workflow.archivedAt} IS NOT NULL` + ) + : and(inArray(workflow.workspaceId, workspaceIds), isNull(workflow.archivedAt)) + ) .orderBy(...orderByClause) } @@ -179,7 +190,13 @@ export async function POST(req: NextRequest) { db .select({ minOrder: min(workflow.sortOrder) }) .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), workflowParentCondition)), + .where( + and( + eq(workflow.workspaceId, workspaceId), + workflowParentCondition, + isNull(workflow.archivedAt) + ) + ), db .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) @@ -197,6 +214,31 @@ export async function POST(req: NextRequest) { sortOrder = minSortOrder != null ? minSortOrder - 1 : 0 } + const duplicateConditions = [ + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + eq(workflow.name, name), + ] + + if (folderId) { + duplicateConditions.push(eq(workflow.folderId, folderId)) + } else { + duplicateConditions.push(isNull(workflow.folderId)) + } + + const [duplicateWorkflow] = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(...duplicateConditions)) + .limit(1) + + if (duplicateWorkflow) { + return NextResponse.json( + { error: `A workflow named "${name}" already exists in this folder` }, + { status: 409 } + ) + } + await db.insert(workflow).values({ id: workflowId, userId, 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 d95daf99ee6..bb9a5ff6989 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 @@ -32,7 +32,7 @@ export async function PUT( const userId = session.user.id const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission || (permission !== 'admin' && permission !== 'write')) { + if (permission !== 'admin') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } @@ -128,7 +128,7 @@ export async function DELETE( const userId = session.user.id const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission || (permission !== 'admin' && permission !== 'write')) { + if (permission !== 'admin') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } 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 680d5cf78a5..c440618863e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -80,7 +80,7 @@ export async function PATCH( /** * DELETE /api/workspaces/[id]/files/[fileId] - * Delete a workspace file (requires write permission) + * Archive a workspace file (requires write permission) */ export async function DELETE( request: NextRequest, @@ -106,7 +106,7 @@ export async function DELETE( await deleteWorkspaceFile(workspaceId, fileId) - logger.info(`[${requestId}] Deleted workspace file: ${fileId}`) + logger.info(`[${requestId}] Archived workspace file: ${fileId}`) recordAudit({ workspaceId, @@ -116,7 +116,7 @@ export async function DELETE( action: AuditAction.FILE_DELETED, resourceType: AuditResourceType.FILE, resourceId: fileId, - description: `Deleted file "${fileId}"`, + description: `Archived file "${fileId}"`, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index a62575dce0e..d6ceb728e00 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -3,7 +3,11 @@ 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 { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + listWorkspaceFiles, + uploadWorkspaceFile, + type WorkspaceFileScope, +} from '@/lib/uploads/contexts/workspace' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' @@ -34,7 +38,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const files = await listWorkspaceFiles(workspaceId) + 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}`) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 8687982d5a6..cf2ed3826d8 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -1,15 +1,16 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' 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 { archiveWorkspace } from '@/lib/workspaces/lifecycle' const logger = createLogger('WorkspaceByIdAPI') import { db } from '@sim/db' -import { knowledgeBase, permissions, templates, workspace } from '@sim/db/schema' +import { permissions, templates, workspace } from '@sim/db/schema' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const patchWorkspaceSchema = z.object({ @@ -84,7 +85,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const workspaceDetails = await db .select() .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .then((rows) => rows[0]) if (!workspaceDetails) { @@ -131,7 +132,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const existingWorkspace = await db .select() .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .then((rows) => rows[0]) if (!existingWorkspace) { @@ -242,67 +243,37 @@ export async function DELETE( const [workspaceRecord] = await db .select({ name: workspace.name }) .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .limit(1) - // Delete workspace and all related data in a transaction - let workspaceWorkflowCount = 0 - await db.transaction(async (tx) => { - // Get all workflows in this workspace before deletion - const workspaceWorkflows = await tx - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - - workspaceWorkflowCount = workspaceWorkflows.length - - if (workspaceWorkflows.length > 0) { - const workflowIds = workspaceWorkflows.map((w) => w.id) - - // Handle templates based on user choice - if (deleteTemplates) { - // Delete published templates that reference these workflows - await tx.delete(templates).where(inArray(templates.workflowId, workflowIds)) - logger.info(`Deleted templates for workflows in workspace ${workspaceId}`) - } else { - // Set workflowId to null for templates to create "orphaned" templates - // This allows templates to remain without source workflows - await tx - .update(templates) - .set({ workflowId: null }) - .where(inArray(templates.workflowId, workflowIds)) - logger.info( - `Updated templates to orphaned status for workflows in workspace ${workspaceId}` - ) - } + 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)) } + } - // Delete all workflows in the workspace - database cascade will handle all workflow-related data - // The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows, - // workflow_logs, workflow_execution_snapshots, workflow_execution_logs, workflow_execution_trace_spans, - // workflow_schedule, webhook, chat, and memory records - await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId)) - - // Clear workspace ID from knowledge bases instead of deleting them - // This allows knowledge bases to become "unassigned" rather than being deleted - await tx - .update(knowledgeBase) - .set({ workspaceId: null, updatedAt: new Date() }) - .where(eq(knowledgeBase.workspaceId, workspaceId)) - - // Delete all permissions associated with this workspace - await tx - .delete(permissions) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - - // Delete the workspace itself - await tx.delete(workspace).where(eq(workspace.id, workspaceId)) - - logger.info(`Successfully deleted workspace ${workspaceId} and all related data`) + const archiveResult = await archiveWorkspace(workspaceId, { + requestId: `workspace-${workspaceId}`, }) + if (!archiveResult.archived && !workspaceRecord) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + recordAudit({ - workspaceId: null, + workspaceId, actorId: session.user.id, actorName: session.user.name, actorEmail: session.user.email, @@ -310,11 +281,12 @@ export async function DELETE( resourceType: AuditResourceType.WORKSPACE, resourceId: workspaceId, resourceName: workspaceRecord?.name, - description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`, + description: `Archived workspace "${workspaceRecord?.name || workspaceId}"`, metadata: { affected: { - workflows: workspaceWorkflowCount, + workflows: workflowIds.length, }, + archived: archiveResult.archived, deleteTemplates, }, request, diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts index 8a6088c643c..3df5bd76884 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockGetSession = vi.fn() const mockHasWorkspaceAdminAccess = vi.fn() +const mockGetWorkspaceById = vi.fn() let dbSelectResults: any[] = [] let dbSelectCallIndex = 0 @@ -63,6 +64,7 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/workspaces/permissions/utils', () => ({ hasWorkspaceAdminAccess: (userId: string, workspaceId: string) => mockHasWorkspaceAdminAccess(userId, workspaceId), + getWorkspaceById: (id: string) => mockGetWorkspaceById(id), })) vi.mock('@/lib/credentials/environment', () => ({ @@ -120,8 +122,9 @@ vi.mock('@sim/db/schema', () => ({ })) vi.mock('drizzle-orm', () => ({ - eq: vi.fn((a, b) => ({ type: 'eq', a, b })), - and: vi.fn((...args) => ({ type: 'and', args })), + eq: vi.fn((a: unknown, b: unknown) => ({ type: 'eq', a, b })), + and: vi.fn((...args: unknown[]) => ({ type: 'and', args })), + isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), })) vi.mock('crypto', () => ({ @@ -164,6 +167,7 @@ describe('Workspace Invitation [invitationId] API Route', () => { vi.clearAllMocks() dbSelectResults = [] dbSelectCallIndex = 0 + mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-456', name: 'Test Workspace' }) }) describe('GET /api/workspaces/invitations/[invitationId]', () => { diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index 69548e13ec9..723b2954de0 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -10,7 +10,7 @@ import { workspaceInvitation, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { WorkspaceInvitationEmail } from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -19,7 +19,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' -import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' +import { getWorkspaceById, hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceInvitationAPI') @@ -74,7 +74,7 @@ export async function GET( const workspaceDetails = await db .select() .from(workspace) - .where(eq(workspace.id, invitation.workspaceId)) + .where(and(eq(workspace.id, invitation.workspaceId), isNull(workspace.archivedAt))) .then((rows) => rows[0]) if (!workspaceDetails) { @@ -237,6 +237,11 @@ export async function DELETE( return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) } + const activeWorkspace = await getWorkspaceById(invitation.workspaceId) + if (!activeWorkspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId) if (!hasAdminAccess) { diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 0919385d0f9..248e721258d 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -15,6 +15,7 @@ const { mockGetEmailDomain, mockValidateInvitationsAllowed, mockRandomUUID, + mockGetWorkspaceById, } = vi.hoisted(() => { const mockGetSession = vi.fn() const mockInsertValues = vi.fn().mockResolvedValue(undefined) @@ -24,6 +25,7 @@ const { const mockGetEmailDomain = vi.fn().mockReturnValue('sim.ai') const mockValidateInvitationsAllowed = vi.fn().mockResolvedValue(undefined) const mockRandomUUID = vi.fn().mockReturnValue('mock-uuid-1234') + const mockGetWorkspaceById = vi.fn() const mockDbResults: { value: any[] } = { value: [] } @@ -52,6 +54,7 @@ const { mockGetEmailDomain, mockValidateInvitationsAllowed, mockRandomUUID, + mockGetWorkspaceById, } }) @@ -111,6 +114,10 @@ vi.mock('@/lib/core/config/env', async () => { return createEnvMock() }) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceById: mockGetWorkspaceById, +})) + vi.mock('@/lib/core/utils/urls', () => ({ getEmailDomain: mockGetEmailDomain, })) @@ -135,6 +142,7 @@ vi.mock('drizzle-orm', () => ({ inArray: vi .fn() .mockImplementation((field: any, values: any) => ({ type: 'inArray', field, values })), + isNull: vi.fn().mockImplementation((field: any) => ({ type: 'isNull', field })), })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ @@ -176,6 +184,7 @@ describe('Workspace Invitations API Route', () => { mockRender.mockResolvedValue('email content') mockGetEmailDomain.mockReturnValue('sim.ai') mockValidateInvitationsAllowed.mockResolvedValue(undefined) + mockGetWorkspaceById.mockResolvedValue({ id: 'workspace-1', name: 'Test Workspace' }) }) describe('GET /api/workspaces/invitations', () => { @@ -291,9 +300,9 @@ describe('Workspace Invitations API Route', () => { it('should return 404 when workspace is not found', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockGetWorkspaceById.mockResolvedValueOnce(null) mockDbResults.value = [ [{ permissionType: 'admin' }], // User has admin permissions - [], // Workspace not found ] const req = createMockRequest('POST', { diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 543cc73727c..208e0a0e267 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -10,7 +10,7 @@ import { workspaceInvitation, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { WorkspaceInvitationEmail } from '@/components/emails' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -19,6 +19,7 @@ import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress } from '@/lib/messaging/email/utils' +import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { InvitationsNotAllowedError, validateInvitationsAllowed, @@ -50,6 +51,7 @@ export async function GET(req: NextRequest) { eq(permissions.userId, session.user.id) ) ) + .where(isNull(workspace.archivedAt)) if (userWorkspaces.length === 0) { return NextResponse.json({ invitations: [] }) @@ -114,10 +116,15 @@ export async function POST(req: NextRequest) { ) } + const activeWorkspace = await getWorkspaceById(workspaceId) + if (!activeWorkspace) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + const workspaceDetails = await db .select() .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) .then((rows) => rows[0]) if (!workspaceDetails) { diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 4d89b7d7272..365b031a93f 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { permissions, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { and, desc, eq, isNull, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' @@ -23,13 +23,18 @@ const createWorkspaceSchema = z.object({ }) // Get all workspaces for the current user -export async function GET() { +export async function GET(request: Request) { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const scope = (new URL(request.url).searchParams.get('scope') ?? 'active') as WorkspaceScope + if (!['active', 'archived', 'all'].includes(scope)) { + return NextResponse.json({ error: 'Invalid scope' }, { status: 400 }) + } + const userWorkspaces = await db .select({ workspace: workspace, @@ -37,10 +42,24 @@ export async function GET() { }) .from(permissions) .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) + .where( + scope === 'all' + ? and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')) + : scope === 'archived' + ? and( + eq(permissions.userId, session.user.id), + eq(permissions.entityType, 'workspace'), + sql`${workspace.archivedAt} IS NOT NULL` + ) + : and( + eq(permissions.userId, session.user.id), + eq(permissions.entityType, 'workspace'), + isNull(workspace.archivedAt) + ) + ) .orderBy(desc(workspace.createdAt)) - if (userWorkspaces.length === 0) { + if (scope === 'active' && userWorkspaces.length === 0) { const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name) await migrateExistingWorkflows(session.user.id, defaultWorkspace.id) @@ -48,7 +67,9 @@ export async function GET() { return NextResponse.json({ workspaces: [defaultWorkspace] }) } - await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id) + if (scope === 'active') { + await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id) + } const workspacesWithPermissions = userWorkspaces.map( ({ workspace: workspaceDetails, permissionType }) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 07ec8505e0f..dc7d29e7081 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -1,6 +1,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' +import { workflowKeys } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -39,6 +41,7 @@ export function useDeleteWorkflow({ onSuccess, }: UseDeleteWorkflowProps) { const router = useRouter() + const queryClient = useQueryClient() const { workflows, removeWorkflow } = useWorkflowRegistry() const [isDeleting, setIsDeleting] = useState(false) @@ -102,6 +105,7 @@ export function useDeleteWorkflow({ } await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id))) + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) const { clearSelection } = useFolderStore.getState() clearSelection() @@ -114,7 +118,17 @@ export function useDeleteWorkflow({ } finally { setIsDeleting(false) } - }, [workflowIds, isDeleting, workflows, workspaceId, isActive, router, removeWorkflow, onSuccess]) + }, [ + workflowIds, + isDeleting, + workflows, + workspaceId, + isActive, + router, + removeWorkflow, + onSuccess, + queryClient, + ]) return { isDeleting, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index b7459a53245..5be5a661268 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -195,7 +195,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { } } - await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) }) + await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) }) logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 41380932716..2391d83b599 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -2,7 +2,7 @@ import { db, jobExecutionLogs, workflow, workflowSchedule } from '@sim/db' import { createLogger } from '@sim/logger' import { task } from '@trigger.dev/sdk' import { Cron } from 'croner' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -21,6 +21,7 @@ import { getSubBlockValue, validateCronExpression, } from '@/lib/workflows/schedules/utils' +import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' import { hasExecutionResult } from '@/executor/utils/errors' @@ -45,7 +46,10 @@ async function applyScheduleUpdate( context: string ) { try { - await db.update(workflowSchedule).set(updates).where(eq(workflowSchedule.id, scheduleId)) + await db + .update(workflowSchedule) + .set(updates) + .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) } catch (error) { logger.error(`[${requestId}] ${context}`, error) } @@ -317,6 +321,37 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { }) 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.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 + } + const loggingSession = new LoggingSession( payload.workflowId, executionId, @@ -482,12 +517,17 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { }) if (executionResult.status === 'skip') { - await releaseScheduleLock( + await applyScheduleUpdate( payload.scheduleId, + { + updatedAt: now, + lastQueuedAt: null, + lastFailedAt: now, + status: 'disabled', + nextRunAt: null, + }, requestId, - now, - `Failed to release schedule ${payload.scheduleId} after skip`, - scheduledFor ?? now + `Failed to disable schedule ${payload.scheduleId} after skip` ) return } @@ -788,7 +828,7 @@ export async function executeJobInline(payload: JobExecutionPayload) { const [jobRecord] = await db .select() .from(workflowSchedule) - .where(eq(workflowSchedule.id, payload.scheduleId)) + .where(and(eq(workflowSchedule.id, payload.scheduleId), isNull(workflowSchedule.archivedAt))) .limit(1) if (!jobRecord || !jobRecord.prompt || !jobRecord.sourceUserId || !jobRecord.sourceWorkspaceId) { @@ -804,6 +844,20 @@ export async function executeJobInline(payload: JobExecutionPayload) { return } + const activeWorkspace = await getWorkspaceById(jobRecord.sourceWorkspaceId) + if (!activeWorkspace || jobRecord.status === 'disabled') { + logger.info(`[${requestId}] Job is archived, disabled, or workspace is inactive`, { + scheduleId: payload.scheduleId, + }) + await releaseScheduleLock( + payload.scheduleId, + requestId, + now, + `Failed to release job ${payload.scheduleId} after archive/disabled check` + ) + return + } + if (jobRecord.status === 'completed') { logger.info(`[${requestId}] Job already completed, skipping`, { scheduleId: payload.scheduleId, diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index 77293b77883..d911c8f0cc6 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -2,7 +2,6 @@ import { createHmac } from 'crypto' import { db } from '@sim/db' import { account, - workflow as workflowTable, workspaceNotificationDelivery, workspaceNotificationSubscription, } from '@sim/db/schema' @@ -25,6 +24,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { sendEmail } from '@/lib/messaging/email/mailer' import type { AlertConfig } from '@/lib/notifications/alert-rules' +import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' const logger = createLogger('WorkspaceNotificationDelivery') @@ -70,23 +70,20 @@ async function buildPayload( log: WorkflowExecutionLog, subscription: typeof workspaceNotificationSubscription.$inferSelect ): Promise { - // Skip notifications for deleted workflows + /** + * Skip notifications when the workflow or workspace has already been archived. + */ if (!log.workflowId) return null - const workflowData = await db - .select({ - name: workflowTable.name, - workspaceId: workflowTable.workspaceId, - }) - .from(workflowTable) - .where(eq(workflowTable.id, log.workflowId)) - .limit(1) + const workflowContext = await getActiveWorkflowContext(log.workflowId) const timestamp = Date.now() const executionData = (log.executionData || {}) as Record - const workflowRecord = workflowData[0] - const userId = workflowRecord?.workspaceId - ? await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId) + if (!workflowContext?.workspaceId) { + return null + } + const userId = workflowContext.workspaceId + ? await getWorkspaceBilledAccountUserId(workflowContext.workspaceId) : null const payload: NotificationPayload = { @@ -95,7 +92,7 @@ async function buildPayload( timestamp, data: { workflowId: log.workflowId, - workflowName: workflowData[0]?.name || 'Unknown Workflow', + workflowName: workflowContext.workflow.name || 'Unknown Workflow', executionId: log.executionId, status: log.level === 'error' ? 'error' : 'success', level: log.level, @@ -537,8 +534,8 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa // Skip delivery for deleted workflows if (!payload) { - await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was deleted') - logger.info(`Skipping delivery ${deliveryId} - workflow was deleted`) + await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was archived or deleted') + logger.info(`Skipping delivery ${deliveryId} - workflow was archived or deleted`) return } diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index b3f537a987e..5b22872feec 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -255,7 +255,7 @@ export function useDeleteFolderMutation() { }, onSuccess: async (_data, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) }, }) } @@ -324,7 +324,7 @@ export function useDuplicateFolderMutation() { ...handlers, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: folderKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) }, }) } diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index d8d6e791671..44360e0f25b 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -10,9 +10,12 @@ import type { const logger = createLogger('KnowledgeQueries') +type KnowledgeQueryScope = 'active' | 'archived' | 'all' + export const knowledgeKeys = { all: ['knowledge'] as const, - list: (workspaceId?: string) => [...knowledgeKeys.all, 'list', workspaceId ?? 'all'] as const, + list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') => + [...knowledgeKeys.all, 'list', workspaceId ?? 'all', scope] as const, detail: (knowledgeBaseId?: string) => [...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const, tagDefinitions: (knowledgeBaseId: string) => @@ -29,9 +32,12 @@ export const knowledgeKeys = { export async function fetchKnowledgeBases( workspaceId?: string, + scope: KnowledgeQueryScope = 'active', signal?: AbortSignal ): Promise { - const url = workspaceId ? `/api/knowledge?workspaceId=${workspaceId}` : '/api/knowledge' + const url = workspaceId + ? `/api/knowledge?workspaceId=${workspaceId}&scope=${scope}` + : `/api/knowledge?scope=${scope}` const response = await fetch(url, { signal }) if (!response.ok) { @@ -228,11 +234,13 @@ export function useKnowledgeBasesQuery( workspaceId?: string, options?: { enabled?: boolean + scope?: KnowledgeQueryScope } ) { + const scope = options?.scope ?? 'active' return useQuery({ - queryKey: knowledgeKeys.list(workspaceId), - queryFn: ({ signal }) => fetchKnowledgeBases(workspaceId, signal), + queryKey: knowledgeKeys.list(workspaceId, scope), + queryFn: ({ signal }) => fetchKnowledgeBases(workspaceId, scope, signal), enabled: options?.enabled ?? true, staleTime: 60 * 1000, placeholderData: keepPreviousData, @@ -721,7 +729,7 @@ export function useCreateKnowledgeBase(workspaceId?: string) { mutationFn: createKnowledgeBase, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: knowledgeKeys.list(workspaceId), + queryKey: knowledgeKeys.all, }) }, }) @@ -769,7 +777,7 @@ export function useUpdateKnowledgeBase(workspaceId?: string) { queryKey: knowledgeKeys.detail(knowledgeBaseId), }) queryClient.invalidateQueries({ - queryKey: knowledgeKeys.list(workspaceId), + queryKey: knowledgeKeys.all, }) }, }) @@ -804,7 +812,7 @@ export function useDeleteKnowledgeBase(workspaceId?: string) { mutationFn: deleteKnowledgeBase, onSuccess: () => { queryClient.invalidateQueries({ - queryKey: knowledgeKeys.list(workspaceId), + queryKey: knowledgeKeys.all, }) }, }) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 087071c4235..984f5ec88cd 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -5,10 +5,13 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Filter, RowData, Sort, TableDefinition, TableMetadata, TableRow } from '@/lib/table' +type TableQueryScope = 'active' | 'archived' | 'all' + export const tableKeys = { all: ['tables'] as const, lists: () => [...tableKeys.all, 'list'] as const, - list: (workspaceId?: string) => [...tableKeys.lists(), workspaceId ?? ''] as const, + list: (workspaceId?: string, scope: TableQueryScope = 'active') => + [...tableKeys.lists(), workspaceId ?? '', scope] as const, details: () => [...tableKeys.all, 'detail'] as const, detail: (tableId: string) => [...tableKeys.details(), tableId] as const, rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const, @@ -129,7 +132,7 @@ function invalidateRowCount( ) { queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) - queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) }) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) } function invalidateTableSchema( @@ -139,21 +142,24 @@ function invalidateTableSchema( ) { queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) - queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) }) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) } /** * Fetch all tables for a workspace. */ -export function useTablesList(workspaceId?: string) { +export function useTablesList(workspaceId?: string, scope: TableQueryScope = 'active') { return useQuery({ - queryKey: tableKeys.list(workspaceId), + queryKey: tableKeys.list(workspaceId, scope), queryFn: async ({ signal }) => { if (!workspaceId) throw new Error('Workspace ID required') - const res = await fetch(`/api/table?workspaceId=${encodeURIComponent(workspaceId)}`, { - signal, - }) + const res = await fetch( + `/api/table?workspaceId=${encodeURIComponent(workspaceId)}&scope=${scope}`, + { + signal, + } + ) if (!res.ok) { const error = await res.json() @@ -239,7 +245,7 @@ export function useCreateTable(workspaceId: string) { return res.json() }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) }) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) }, }) } @@ -300,7 +306,7 @@ export function useRenameTable(workspaceId: string) { }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: tableKeys.detail(variables.tableId) }) - queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) }) + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) }, }) } @@ -327,8 +333,10 @@ export function useDeleteTable(workspaceId: string) { return res.json() }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) }) + onSettled: (_data, _error, tableId) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) }) + queryClient.removeQueries({ queryKey: tableKeys.rowsRoot(tableId) }) }, }) } diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 122cc408a4c..95070070a90 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -18,10 +18,13 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowQueries') +type WorkflowQueryScope = 'active' | 'archived' | 'all' + export const workflowKeys = { all: ['workflows'] as const, lists: () => [...workflowKeys.all, 'list'] as const, - list: (workspaceId: string | undefined) => [...workflowKeys.lists(), workspaceId ?? ''] as const, + list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => + [...workflowKeys.lists(), workspaceId ?? '', scope] as const, deploymentStatus: (workflowId: string | undefined) => [...workflowKeys.all, 'deploymentStatus', workflowId ?? ''] as const, deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, @@ -78,9 +81,12 @@ function mapWorkflow(workflow: any): WorkflowMetadata { async function fetchWorkflows( workspaceId: string, + scope: WorkflowQueryScope = 'active', signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`, { signal }) + const response = await fetch(`/api/workflows?workspaceId=${workspaceId}&scope=${scope}`, { + signal, + }) if (!response.ok) { throw new Error('Failed to fetch workflows') @@ -90,39 +96,48 @@ async function fetchWorkflows( return data.map(mapWorkflow) } -export function useWorkflows(workspaceId?: string, options?: { syncRegistry?: boolean }) { - const { syncRegistry = true } = options || {} +export function useWorkflows( + workspaceId?: string, + options?: { syncRegistry?: boolean; scope?: WorkflowQueryScope } +) { + const { syncRegistry = true, scope = 'active' } = options || {} const beginMetadataLoad = useWorkflowRegistry((state) => state.beginMetadataLoad) const completeMetadataLoad = useWorkflowRegistry((state) => state.completeMetadataLoad) const failMetadataLoad = useWorkflowRegistry((state) => state.failMetadataLoad) const query = useQuery({ - queryKey: workflowKeys.list(workspaceId), - queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, signal), + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, staleTime: 60 * 1000, }) useEffect(() => { - if (syncRegistry && workspaceId && query.status === 'pending') { + if (syncRegistry && scope === 'active' && workspaceId && query.status === 'pending') { beginMetadataLoad(workspaceId) } - }, [syncRegistry, workspaceId, query.status, beginMetadataLoad]) + }, [syncRegistry, scope, workspaceId, query.status, beginMetadataLoad]) useEffect(() => { - if (syncRegistry && workspaceId && query.status === 'success' && query.data) { + if ( + syncRegistry && + scope === 'active' && + workspaceId && + query.status === 'success' && + query.data + ) { completeMetadataLoad(workspaceId, query.data) } - }, [syncRegistry, workspaceId, query.status, query.data, completeMetadataLoad]) + }, [syncRegistry, scope, workspaceId, query.status, query.data, completeMetadataLoad]) useEffect(() => { - if (syncRegistry && workspaceId && query.status === 'error') { + if (syncRegistry && scope === 'active' && workspaceId && query.status === 'error') { const message = query.error instanceof Error ? query.error.message : 'Failed to fetch workflows' failMetadataLoad(workspaceId, message) } - }, [syncRegistry, workspaceId, query.status, query.error, failMetadataLoad]) + }, [syncRegistry, scope, workspaceId, query.status, query.error, failMetadataLoad]) return query } @@ -185,7 +200,7 @@ function createWorkflowMutationHandlers(queryClient, { name, - getQueryKey: (variables) => workflowKeys.list(variables.workspaceId), + getQueryKey: (variables) => workflowKeys.list(variables.workspaceId, 'active'), getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }), generateTempId: customGenerateTempId ?? (() => generateTempId('temp-workflow')), createOptimisticItem: createOptimisticWorkflow, @@ -542,7 +557,7 @@ export function useReorderWorkflows() { } }, onMutate: async (variables) => { - await queryClient.cancelQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) + await queryClient.cancelQueries({ queryKey: workflowKeys.lists() }) const snapshot = { ...useWorkflowRegistry.getState().workflows } @@ -569,7 +584,7 @@ export function useReorderWorkflows() { } }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) }, }) } @@ -748,7 +763,7 @@ export function useImportWorkflow() { return data }, onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.list(variables.targetWorkspaceId) }) + queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) }, }) } diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 4298a7637b7..5c543191e19 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -4,13 +4,16 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' const logger = createLogger('WorkspaceFilesQuery') +type WorkspaceFileQueryScope = 'active' | 'archived' | 'all' + /** * Query key factories for workspace files */ export const workspaceFilesKeys = { all: ['workspaceFiles'] as const, lists: () => [...workspaceFilesKeys.all, 'list'] as const, - list: (workspaceId: string) => [...workspaceFilesKeys.lists(), workspaceId] as const, + list: (workspaceId: string, scope: WorkspaceFileQueryScope = 'active') => + [...workspaceFilesKeys.lists(), workspaceId, scope] as const, contents: () => [...workspaceFilesKeys.all, 'content'] as const, content: (workspaceId: string, fileId: string) => [...workspaceFilesKeys.contents(), workspaceId, fileId] as const, @@ -32,9 +35,10 @@ export interface StorageInfo { */ async function fetchWorkspaceFiles( workspaceId: string, + scope: WorkspaceFileQueryScope = 'active', signal?: AbortSignal ): Promise { - const response = await fetch(`/api/workspaces/${workspaceId}/files`, { signal }) + const response = await fetch(`/api/workspaces/${workspaceId}/files?scope=${scope}`, { signal }) if (!response.ok) { throw new Error('Failed to fetch workspace files') @@ -48,10 +52,10 @@ async function fetchWorkspaceFiles( /** * Hook to fetch workspace files */ -export function useWorkspaceFiles(workspaceId: string) { +export function useWorkspaceFiles(workspaceId: string, scope: WorkspaceFileQueryScope = 'active') { return useQuery({ - queryKey: workspaceFilesKeys.list(workspaceId), - queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, signal), + queryKey: workspaceFilesKeys.list(workspaceId, scope), + queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, scope, signal), enabled: !!workspaceId, staleTime: 30 * 1000, // 30 seconds - files can change frequently placeholderData: keepPreviousData, // Show cached data immediately @@ -157,7 +161,7 @@ export function useUploadWorkspaceFile() { }, onSuccess: (_data, variables) => { // Invalidate files list to refetch - queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) // Invalidate storage info to update usage queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, @@ -199,7 +203,7 @@ export function useUpdateWorkspaceFileContent() { queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.content(variables.workspaceId, variables.fileId), }) - queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, onError: (error) => { @@ -242,7 +246,7 @@ export function useRenameWorkspaceFile() { return data }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) }, }) } @@ -253,7 +257,6 @@ export function useRenameWorkspaceFile() { interface DeleteFileParams { workspaceId: string fileId: string - fileSize: number } export function useDeleteWorkspaceFile() { @@ -273,52 +276,36 @@ export function useDeleteWorkspaceFile() { return data }, - onMutate: async ({ workspaceId, fileId, fileSize }) => { - await Promise.all([ - queryClient.cancelQueries({ queryKey: workspaceFilesKeys.list(workspaceId) }), - queryClient.cancelQueries({ queryKey: workspaceFilesKeys.storageInfo() }), - ]) + onMutate: async ({ workspaceId, fileId }) => { + await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.lists() }) const previousFiles = queryClient.getQueryData( - workspaceFilesKeys.list(workspaceId) - ) - const previousStorage = queryClient.getQueryData( - workspaceFilesKeys.storageInfo() + workspaceFilesKeys.list(workspaceId, 'active') ) if (previousFiles) { queryClient.setQueryData( - workspaceFilesKeys.list(workspaceId), + workspaceFilesKeys.list(workspaceId, 'active'), previousFiles.filter((f) => f.id !== fileId) ) } - if (previousStorage) { - const newUsedBytes = Math.max(0, previousStorage.usedBytes - fileSize) - const newPercentUsed = (newUsedBytes / previousStorage.limitBytes) * 100 - queryClient.setQueryData(workspaceFilesKeys.storageInfo(), { - ...previousStorage, - usedBytes: newUsedBytes, - percentUsed: newPercentUsed, - }) - } - - return { previousFiles, previousStorage } + return { previousFiles } }, onError: (_err, variables, context) => { if (context?.previousFiles) { queryClient.setQueryData( - workspaceFilesKeys.list(variables.workspaceId), + workspaceFilesKeys.list(variables.workspaceId, 'active'), context.previousFiles ) } - if (context?.previousStorage) { - queryClient.setQueryData(workspaceFilesKeys.storageInfo(), context.previousStorage) - } logger.error('Failed to delete file') }, onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) + queryClient.removeQueries({ + queryKey: workspaceFilesKeys.content(variables.workspaceId, variables.fileId), + }) queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }) }, }) diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index 63a76ee8754..bcc580e1962 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -4,10 +4,13 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta * Query key factory for workspace-related queries. * Provides hierarchical cache keys for workspaces, settings, and permissions. */ +type WorkspaceQueryScope = 'active' | 'archived' | 'all' + export const workspaceKeys = { all: ['workspace'] as const, lists: () => [...workspaceKeys.all, 'list'] as const, - list: () => [...workspaceKeys.lists(), 'user'] as const, + list: (scope: WorkspaceQueryScope = 'active') => + [...workspaceKeys.lists(), 'user', scope] as const, details: () => [...workspaceKeys.all, 'detail'] as const, detail: (id: string) => [...workspaceKeys.details(), id] as const, settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const, @@ -28,8 +31,11 @@ export interface Workspace { permissions?: 'admin' | 'write' | 'read' | null } -async function fetchWorkspaces(signal?: AbortSignal): Promise { - const response = await fetch('/api/workspaces', { signal }) +async function fetchWorkspaces( + scope: WorkspaceQueryScope = 'active', + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workspaces?scope=${scope}`, { signal }) if (!response.ok) { throw new Error('Failed to fetch workspaces') @@ -43,10 +49,10 @@ async function fetchWorkspaces(signal?: AbortSignal): Promise { * Fetches the current user's workspaces. * @param enabled - Whether the query should execute (defaults to true) */ -export function useWorkspacesQuery(enabled = true) { +export function useWorkspacesQuery(enabled = true, scope: WorkspaceQueryScope = 'active') { return useQuery({ - queryKey: workspaceKeys.list(), - queryFn: ({ signal }) => fetchWorkspaces(signal), + queryKey: workspaceKeys.list(scope), + queryFn: ({ signal }) => fetchWorkspaces(scope, signal), enabled, staleTime: 30 * 1000, placeholderData: keepPreviousData, @@ -80,8 +86,12 @@ export function useCreateWorkspace() { const data = await response.json() return data.workspace as Workspace }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() }) + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: workspaceKeys.all }) + queryClient.removeQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) }) + queryClient.removeQueries({ queryKey: workspaceKeys.settings(variables.workspaceId) }) + queryClient.removeQueries({ queryKey: workspaceKeys.permissions(variables.workspaceId) }) + queryClient.removeQueries({ queryKey: workspaceKeys.members(variables.workspaceId) }) }, }) } diff --git a/apps/sim/lib/a2a/utils.ts b/apps/sim/lib/a2a/utils.ts index 11d3c7ab516..ad7c738f5bc 100644 --- a/apps/sim/lib/a2a/utils.ts +++ b/apps/sim/lib/a2a/utils.ts @@ -1,4 +1,13 @@ -import type { DataPart, FilePart, Message, Part, Task, TaskState, TextPart } from '@a2a-js/sdk' +import type { + Artifact, + DataPart, + FilePart, + Message, + Part, + Task, + TaskState, + TextPart, +} from '@a2a-js/sdk' import { type BeforeArgs, type CallInterceptor, @@ -245,6 +254,12 @@ export interface ParsedSSEChunk { content: string /** Final content if this chunk contains the final event */ finalContent?: string + /** Final success flag if this chunk contains the final event */ + finalSuccess?: boolean + /** Terminal task state if known */ + terminalState?: 'completed' | 'failed' | 'canceled' + /** Final artifacts if present on terminal event */ + finalArtifacts?: Artifact[] /** Whether this chunk indicates the stream is done */ isDone: boolean } @@ -282,10 +297,41 @@ export function parseWorkflowSSEChunk(chunk: string): ParsedSSEChunk { try { const parsed = JSON.parse(dataContent) - if (parsed.event === 'chunk' && parsed.data?.content) { - result.content += parsed.data.content - } else if (parsed.event === 'final' && parsed.data?.output?.content) { - result.finalContent = parsed.data.output.content + if ( + (parsed.event === 'chunk' && parsed.data?.content) || + (parsed.type === 'stream:chunk' && parsed.data?.chunk) + ) { + const chunkText = parsed.data?.content ?? parsed.data?.chunk + if (chunkText) { + result.content += chunkText + } + } else if (parsed.event === 'error' || parsed.type === 'execution:error') { + result.finalSuccess = false + result.terminalState = 'failed' + result.isDone = true + } else if (parsed.type === 'execution:completed') { + if (parsed.data?.output?.content) { + result.finalContent = parsed.data.output.content + } else if (parsed.data?.output) { + result.finalContent = JSON.stringify(parsed.data.output) + } + result.finalArtifacts = (parsed.data?.output?.artifacts as Artifact[] | undefined) || [] + result.finalSuccess = parsed.data?.success !== false + result.terminalState = result.finalSuccess ? 'completed' : 'failed' + result.isDone = true + } else if (parsed.type === 'execution:cancelled') { + result.finalSuccess = false + result.terminalState = 'canceled' + result.isDone = true + } else if (parsed.event === 'final') { + if (parsed.data?.output?.content) { + result.finalContent = parsed.data.output.content + } else if (parsed.data?.output) { + result.finalContent = JSON.stringify(parsed.data.output) + } + result.finalArtifacts = (parsed.data?.output?.artifacts as Artifact[] | undefined) || [] + result.finalSuccess = parsed.data?.success !== false + result.terminalState = result.finalSuccess ? 'completed' : 'failed' result.isDone = true } } catch { diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 127feb9af31..1c45b47168e 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { getRotatingApiKey } from '@/lib/core/config/api-keys' import { isHosted } from '@/lib/core/config/feature-flags' import { decryptSecret } from '@/lib/core/security/encryption' +import { getWorkspaceById } from '@/lib/workspaces/permissions/utils' import { getHostedModels } from '@/providers/models' import { useProvidersStore } from '@/stores/providers/store' import type { BYOKProviderId } from '@/tools/types' @@ -25,6 +26,11 @@ export async function getBYOKKey( } try { + const activeWorkspace = await getWorkspaceById(workspaceId) + if (!activeWorkspace) { + return null + } + const result = await db .select({ encryptedApiKey: workspaceBYOKKeys.encryptedApiKey }) .from(workspaceBYOKKeys) diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 0cc00e72e95..20b52e9e1bd 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -9,6 +9,7 @@ const logger = createLogger('HybridAuth') export interface AuthResult { success: boolean userId?: string + workspaceId?: string userName?: string | null userEmail?: string | null authType?: 'session' | 'api_key' | 'internal_jwt' @@ -208,6 +209,7 @@ export async function checkHybridAuth( return { success: true, userId: result.userId!, + workspaceId: result.workspaceId, authType: 'api_key', apiKeyType: result.keyType, } diff --git a/apps/sim/lib/copilot/auth/permissions.test.ts b/apps/sim/lib/copilot/auth/permissions.test.ts index 639774e1826..46aaf84bf39 100644 --- a/apps/sim/lib/copilot/auth/permissions.test.ts +++ b/apps/sim/lib/copilot/auth/permissions.test.ts @@ -1,35 +1,20 @@ /** * @vitest-environment node */ -import { drizzleOrmMock, loggerMock } from '@sim/testing' +import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSelect, mockFrom, mockWhere, mockLimit, mockGetUserEntityPermissions } = vi.hoisted( - () => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockLimit: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), - }) -) - -vi.mock('@sim/db', () => ({ - db: { - select: mockSelect, - }, -})) - -vi.mock('@sim/db/schema', () => ({ - workflow: { - id: 'id', - workspaceId: 'workspaceId', - }, +const { mockGetActiveWorkflowContext, mockGetUserEntityPermissions } = vi.hoisted(() => ({ + mockGetActiveWorkflowContext: vi.fn(), + mockGetUserEntityPermissions: vi.fn(), })) -vi.mock('drizzle-orm', () => drizzleOrmMock) vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/workflows/active-context', () => ({ + getActiveWorkflowContext: mockGetActiveWorkflowContext, +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -40,15 +25,12 @@ describe('Copilot Auth Permissions', () => { beforeEach(() => { vi.clearAllMocks() - mockSelect.mockReturnValue({ from: mockFrom }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockWhere.mockReturnValue({ limit: mockLimit }) - mockLimit.mockResolvedValue([]) + mockGetActiveWorkflowContext.mockResolvedValue(null) }) describe('verifyWorkflowAccess', () => { it('should return no access for non-existent workflow', async () => { - mockLimit.mockResolvedValueOnce([]) + mockGetActiveWorkflowContext.mockResolvedValueOnce(null) const result = await verifyWorkflowAccess('user-123', 'non-existent-workflow') @@ -59,10 +41,10 @@ describe('Copilot Auth Permissions', () => { }) it('should check workspace permissions for workflow with workspace', async () => { - const workflowData = { + mockGetActiveWorkflowContext.mockResolvedValueOnce({ + workflow: {}, workspaceId: 'workspace-456', - } - mockLimit.mockResolvedValueOnce([workflowData]) + }) mockGetUserEntityPermissions.mockResolvedValueOnce('write') const result = await verifyWorkflowAccess('user-123', 'workflow-789') @@ -81,10 +63,10 @@ describe('Copilot Auth Permissions', () => { }) it('should return read permission through workspace', async () => { - const workflowData = { + mockGetActiveWorkflowContext.mockResolvedValueOnce({ + workflow: {}, workspaceId: 'workspace-456', - } - mockLimit.mockResolvedValueOnce([workflowData]) + }) mockGetUserEntityPermissions.mockResolvedValueOnce('read') const result = await verifyWorkflowAccess('user-123', 'workflow-789') @@ -97,10 +79,10 @@ describe('Copilot Auth Permissions', () => { }) it('should return admin permission through workspace', async () => { - const workflowData = { + mockGetActiveWorkflowContext.mockResolvedValueOnce({ + workflow: {}, workspaceId: 'workspace-456', - } - mockLimit.mockResolvedValueOnce([workflowData]) + }) mockGetUserEntityPermissions.mockResolvedValueOnce('admin') const result = await verifyWorkflowAccess('user-123', 'workflow-789') @@ -113,10 +95,10 @@ describe('Copilot Auth Permissions', () => { }) it('should return no access without workspace permissions', async () => { - const workflowData = { + mockGetActiveWorkflowContext.mockResolvedValueOnce({ + workflow: {}, workspaceId: 'workspace-456', - } - mockLimit.mockResolvedValueOnce([workflowData]) + }) mockGetUserEntityPermissions.mockResolvedValueOnce(null) const result = await verifyWorkflowAccess('user-123', 'workflow-789') @@ -129,22 +111,18 @@ describe('Copilot Auth Permissions', () => { }) it('should return no access for workflow without workspace', async () => { - const workflowData = { - workspaceId: null, - } - mockLimit.mockResolvedValueOnce([workflowData]) + mockGetActiveWorkflowContext.mockResolvedValueOnce(null) const result = await verifyWorkflowAccess('user-123', 'workflow-789') expect(result).toEqual({ hasAccess: false, userPermission: null, - workspaceId: undefined, }) }) it('should handle database errors gracefully', async () => { - mockLimit.mockRejectedValueOnce(new Error('Database connection failed')) + mockGetActiveWorkflowContext.mockRejectedValueOnce(new Error('Database connection failed')) const result = await verifyWorkflowAccess('user-123', 'workflow-789') @@ -155,11 +133,10 @@ describe('Copilot Auth Permissions', () => { }) it('should handle permission check errors gracefully', async () => { - const workflowData = { - userId: 'other-user', + mockGetActiveWorkflowContext.mockResolvedValueOnce({ + workflow: {}, workspaceId: 'workspace-456', - } - mockLimit.mockResolvedValueOnce([workflowData]) + }) mockGetUserEntityPermissions.mockRejectedValueOnce(new Error('Permission check failed')) const result = await verifyWorkflowAccess('user-123', 'workflow-789') diff --git a/apps/sim/lib/copilot/auth/permissions.ts b/apps/sim/lib/copilot/auth/permissions.ts index e45d61a551f..556bfdb0fb5 100644 --- a/apps/sim/lib/copilot/auth/permissions.ts +++ b/apps/sim/lib/copilot/auth/permissions.ts @@ -1,7 +1,5 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { getUserEntityPermissions, type PermissionType } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotPermissions') @@ -22,15 +20,8 @@ export async function verifyWorkflowAccess( workspaceId?: string }> { try { - const workflowData = await db - .select({ - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData.length) { + const workflowContext = await getActiveWorkflowContext(workflowId) + if (!workflowContext) { logger.warn('Attempt to access non-existent workflow', { workflowId, userId, @@ -38,18 +29,7 @@ export async function verifyWorkflowAccess( return { hasAccess: false, userPermission: null } } - const { workspaceId } = workflowData[0] - if (!workspaceId) { - logger.warn('Workflow is not attached to a workspace; access denied', { - workflowId, - userId, - }) - return { - hasAccess: false, - userPermission: null, - workspaceId: undefined, - } - } + const { workspaceId } = workflowContext const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId) diff --git a/apps/sim/lib/copilot/chat-lifecycle.ts b/apps/sim/lib/copilot/chat-lifecycle.ts index 80fff00f9ad..caa4840714d 100644 --- a/apps/sim/lib/copilot/chat-lifecycle.ts +++ b/apps/sim/lib/copilot/chat-lifecycle.ts @@ -2,6 +2,12 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { + assertActiveWorkspaceAccess, + checkWorkspaceAccess, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('CopilotChatLifecycle') @@ -12,6 +18,36 @@ export interface ChatLoadResult { isNew: boolean } +export async function getAccessibleCopilotChat(chatId: string, userId: string) { + const [chat] = await db + .select() + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + .limit(1) + + if (!chat) { + return null + } + + if (chat.workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: chat.workflowId, + userId, + action: 'read', + }) + if (!authorization.allowed || !authorization.workflow) { + return null + } + } else if (chat.workspaceId) { + const access = await checkWorkspaceAccess(chat.workspaceId, userId) + if (!access.exists || !access.hasAccess) { + return null + } + } + + return chat +} + /** * Resolve or create a copilot chat session. * If chatId is provided, loads the existing chat. Otherwise creates a new one. @@ -27,12 +63,29 @@ export async function resolveOrCreateChat(params: { }): Promise { const { chatId, userId, workflowId, workspaceId, model, type } = params + if (workspaceId) { + await assertActiveWorkspaceAccess(workspaceId, userId) + } + if (chatId) { - const [chat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .limit(1) + const chat = await getAccessibleCopilotChat(chatId, userId) + + if (chat) { + if (workflowId && chat.workflowId !== workflowId) { + return { chatId, chat: null, conversationHistory: [], isNew: false } + } + + if (workspaceId && chat.workspaceId !== workspaceId) { + return { chatId, chat: null, conversationHistory: [], isNew: false } + } + + if (chat.workflowId) { + const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId) + if (!activeWorkflow) { + return { chatId, chat: null, conversationHistory: [], isNew: false } + } + } + } return { chatId, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts index f6c6eeef35c..7d62753209d 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts @@ -1,7 +1,8 @@ import { db } from '@sim/db' import { permissions, workspace } from '@sim/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, isNull } from 'drizzle-orm' import { authorizeWorkflowByWorkspacePermission, type getWorkflowById } from '@/lib/workflows/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' type WorkflowRecord = NonNullable>> @@ -34,7 +35,13 @@ export async function getDefaultWorkspaceId(userId: string): Promise { .select({ workspaceId: workspace.id }) .from(permissions) .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + isNull(workspace.archivedAt) + ) + ) .orderBy(desc(workspace.createdAt)) .limit(1) @@ -51,25 +58,16 @@ export async function ensureWorkspaceAccess( userId: string, requireWrite: boolean ): Promise { - const [row] = await db - .select({ - permissionType: permissions.permissionType, - }) - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.userId, userId) - ) - ) - .limit(1) - - if (!row) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { throw new Error(`Workspace ${workspaceId} not found`) } - const permissionType = row.permissionType + const permissionType = access.canWrite + ? 'write' + : access.workspace?.ownerId === userId + ? 'admin' + : 'read' const canWrite = permissionType === 'admin' || permissionType === 'write' if (requireWrite && !canWrite) { diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts index 72acbfe5411..1badd7806ee 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts @@ -1,11 +1,14 @@ import crypto from 'crypto' import { db } from '@sim/db' import { chat, workflowMcpTool } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { mcpPubSub } from '@/lib/mcp/pubsub' -import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' +import { + generateParameterSchemaForWorkflow, + removeMcpToolsForWorkflow, +} from '@/lib/mcp/workflow-mcp-sync' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/persistence/utils' import { checkChatAccess, checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils' @@ -29,6 +32,7 @@ export async function executeDeployApi( if (!result.success) { return { success: false, error: result.error || 'Failed to undeploy workflow' } } + await removeMcpToolsForWorkflow(workflowId, crypto.randomUUID().slice(0, 8)) return { success: true, output: { workflowId, isDeployed: false } } } @@ -70,7 +74,11 @@ export async function executeDeployChat( const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' if (action === 'undeploy') { - const existing = await db.select().from(chat).where(eq(chat.workflowId, workflowId)).limit(1) + const existing = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1) if (!existing.length) { return { success: false, error: 'No active chat deployment found for this workflow' } } @@ -87,7 +95,11 @@ export async function executeDeployChat( return { success: false, error: 'Workflow not found or access denied' } } - const existing = await db.select().from(chat).where(eq(chat.workflowId, workflowId)).limit(1) + const existing = await db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1) const existingDeployment = existing[0] || null const identifier = String(params.identifier || existingDeployment?.identifier || '').trim() @@ -107,7 +119,7 @@ export async function executeDeployChat( const existingIdentifier = await db .select() .from(chat) - .where(eq(chat.identifier, identifier)) + .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt))) .limit(1) if (existingIdentifier.length > 0 && existingIdentifier[0].id !== existingDeployment?.id) { return { success: false, error: 'Identifier already in use' } @@ -257,7 +269,11 @@ export async function executeDeployMcp( .select() .from(workflowMcpTool) .where( - and(eq(workflowMcpTool.serverId, serverId), eq(workflowMcpTool.workflowId, workflowId)) + and( + eq(workflowMcpTool.serverId, serverId), + eq(workflowMcpTool.workflowId, workflowId), + isNull(workflowMcpTool.archivedAt) + ) ) .limit(1) diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts index 43560423e29..6669867b05a 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts @@ -7,7 +7,7 @@ import { workflowMcpServer, workflowMcpTool, } from '@sim/db/schema' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' @@ -36,7 +36,11 @@ export async function executeCheckDeploymentStatus( const [apiDeploy, chatDeploy] = await Promise.all([ db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1), - db.select().from(chat).where(eq(chat.workflowId, workflowId)).limit(1), + db + .select() + .from(chat) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + .limit(1), ]) const isApiDeployed = apiDeploy[0]?.isDeployed || false @@ -131,7 +135,9 @@ export async function executeListWorkspaceMcpServers( description: workflowMcpServer.description, }) .from(workflowMcpServer) - .where(eq(workflowMcpServer.workspaceId, workspaceId)) + .where( + and(eq(workflowMcpServer.workspaceId, workspaceId), isNull(workflowMcpServer.deletedAt)) + ) const serverIds = servers.map((server) => server.id) const tools = @@ -142,7 +148,9 @@ export async function executeListWorkspaceMcpServers( toolName: workflowMcpTool.toolName, }) .from(workflowMcpTool) - .where(inArray(workflowMcpTool.serverId, serverIds)) + .where( + and(inArray(workflowMcpTool.serverId, serverIds), isNull(workflowMcpTool.archivedAt)) + ) : [] const toolNamesByServer: Record = {} @@ -303,7 +311,7 @@ export async function executeDeleteWorkspaceMcpServer( workspaceId: workflowMcpServer.workspaceId, }) .from(workflowMcpServer) - .where(eq(workflowMcpServer.id, serverId)) + .where(and(eq(workflowMcpServer.id, serverId), isNull(workflowMcpServer.deletedAt))) .limit(1) if (!existing) { diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts index 93e4761c5dc..f9c48ab5beb 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts @@ -1,13 +1,20 @@ import { db } from '@sim/db' import { copilotChats, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils' const logger = createLogger('JobTools') +const ACTIVE_JOB_CONDITION = (workspaceId: string) => + and( + eq(workflowSchedule.sourceWorkspaceId, workspaceId), + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt) + ) + interface CreateJobParams { title?: string prompt: string @@ -225,12 +232,7 @@ export async function executeManageJob( createdAt: workflowSchedule.createdAt, }) .from(workflowSchedule) - .where( - and( - eq(workflowSchedule.sourceWorkspaceId, context.workspaceId), - eq(workflowSchedule.sourceType, 'job') - ) - ) + .where(ACTIVE_JOB_CONDITION(context.workspaceId)) return { success: true, @@ -272,11 +274,7 @@ export async function executeManageJob( .select() .from(workflowSchedule) .where( - and( - eq(workflowSchedule.id, args.jobId), - eq(workflowSchedule.sourceType, 'job'), - eq(workflowSchedule.sourceWorkspaceId, context.workspaceId) - ) + and(eq(workflowSchedule.id, args.jobId), ACTIVE_JOB_CONDITION(context.workspaceId)) ) .limit(1) @@ -322,11 +320,7 @@ export async function executeManageJob( .select({ id: workflowSchedule.id }) .from(workflowSchedule) .where( - and( - eq(workflowSchedule.id, args.jobId), - eq(workflowSchedule.sourceType, 'job'), - eq(workflowSchedule.sourceWorkspaceId, context.workspaceId) - ) + and(eq(workflowSchedule.id, args.jobId), ACTIVE_JOB_CONDITION(context.workspaceId)) ) .limit(1) @@ -380,7 +374,10 @@ export async function executeManageJob( updates.maxRuns = args.maxRuns } - await db.update(workflowSchedule).set(updates).where(eq(workflowSchedule.id, args.jobId)) + await db + .update(workflowSchedule) + .set(updates) + .where(and(eq(workflowSchedule.id, args.jobId), isNull(workflowSchedule.archivedAt))) logger.info('Job updated', { jobId: args.jobId, fields: Object.keys(updates) }) @@ -410,11 +407,7 @@ export async function executeManageJob( .select({ id: workflowSchedule.id }) .from(workflowSchedule) .where( - and( - eq(workflowSchedule.id, args.jobId), - eq(workflowSchedule.sourceType, 'job'), - eq(workflowSchedule.sourceWorkspaceId, context.workspaceId) - ) + and(eq(workflowSchedule.id, args.jobId), ACTIVE_JOB_CONDITION(context.workspaceId)) ) .limit(1) @@ -464,7 +457,13 @@ export async function executeCompleteJob( sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, }) .from(workflowSchedule) - .where(and(eq(workflowSchedule.id, jobId), eq(workflowSchedule.sourceType, 'job'))) + .where( + and( + eq(workflowSchedule.id, jobId), + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt) + ) + ) .limit(1) if (!job) { @@ -489,7 +488,7 @@ export async function executeCompleteJob( nextRunAt: null, updatedAt: new Date(), }) - .where(eq(workflowSchedule.id, jobId)) + .where(and(eq(workflowSchedule.id, jobId), isNull(workflowSchedule.archivedAt))) logger.info('Job completed', { jobId }) @@ -526,13 +525,7 @@ export async function executeUpdateJobHistory( jobHistory: workflowSchedule.jobHistory, }) .from(workflowSchedule) - .where( - and( - eq(workflowSchedule.id, jobId), - eq(workflowSchedule.sourceType, 'job'), - eq(workflowSchedule.sourceWorkspaceId, context.workspaceId) - ) - ) + .where(and(eq(workflowSchedule.id, jobId), ACTIVE_JOB_CONDITION(context.workspaceId))) .limit(1) if (!job) { @@ -545,7 +538,7 @@ export async function executeUpdateJobHistory( await db .update(workflowSchedule) .set({ jobHistory: updated, updatedAt: new Date() }) - .where(eq(workflowSchedule.id, jobId)) + .where(and(eq(workflowSchedule.id, jobId), isNull(workflowSchedule.archivedAt))) logger.info('Job history updated', { jobId, entryCount: updated.length }) diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index f1f9da72666..4aee215fe90 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -1,14 +1,18 @@ import { db } from '@sim/db' -import { copilotChats, document, knowledgeBase, templates } from '@sim/db/schema' +import { document, knowledgeBase, templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { readFileRecord } from '@/lib/copilot/vfs/file-reader' import { serializeTableMeta } from '@/lib/copilot/vfs/serializers' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { getTableById } from '@/lib/table/service' +import { canAccessTemplate } from '@/lib/templates/permissions' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' @@ -46,24 +50,37 @@ export async function processContexts( if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) { return await processWorkflowFromDb( ctx.workflowId, + undefined, ctx.label ? `@${ctx.label}` : '@', ctx.kind ) } if (ctx.kind === 'knowledge' && ctx.knowledgeId) { - return await processKnowledgeFromDb(ctx.knowledgeId, ctx.label ? `@${ctx.label}` : '@') + return await processKnowledgeFromDb( + ctx.knowledgeId, + undefined, + ctx.label ? `@${ctx.label}` : '@' + ) } if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { return await processBlockMetadata(ctx.blockIds[0], ctx.label ? `@${ctx.label}` : '@') } if (ctx.kind === 'templates' && ctx.templateId) { - return await processTemplateFromDb(ctx.templateId, ctx.label ? `@${ctx.label}` : '@') + return await processTemplateFromDb( + ctx.templateId, + undefined, + ctx.label ? `@${ctx.label}` : '@' + ) } if (ctx.kind === 'logs' && ctx.executionId) { - return await processExecutionLogFromDb(ctx.executionId, ctx.label ? `@${ctx.label}` : '@') + return await processExecutionLogFromDb( + ctx.executionId, + undefined, + ctx.label ? `@${ctx.label}` : '@' + ) } if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { - return await processWorkflowBlockFromDb(ctx.workflowId, ctx.blockId, ctx.label) + return await processWorkflowBlockFromDb(ctx.workflowId, undefined, ctx.blockId, ctx.label) } // Other kinds can be added here: workflow, blocks, logs, knowledge, templates, docs return null @@ -82,26 +99,34 @@ export async function processContextsServer( contexts: ChatContext[] | undefined, userId: string, userMessage?: string, - workspaceId?: string + currentWorkspaceId?: string ): Promise { if (!Array.isArray(contexts) || contexts.length === 0) return [] const tasks = contexts.map(async (ctx) => { try { if (ctx.kind === 'past_chat' && ctx.chatId) { - return await processPastChatFromDb(ctx.chatId, userId, ctx.label ? `@${ctx.label}` : '@') + return await processPastChatFromDb( + ctx.chatId, + userId, + ctx.label ? `@${ctx.label}` : '@', + currentWorkspaceId + ) } if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) { return await processWorkflowFromDb( ctx.workflowId, + userId, ctx.label ? `@${ctx.label}` : '@', - ctx.kind + ctx.kind, + currentWorkspaceId ) } if (ctx.kind === 'knowledge' && ctx.knowledgeId) { return await processKnowledgeFromDb( ctx.knowledgeId, + userId, ctx.label ? `@${ctx.label}` : '@', - workspaceId + currentWorkspaceId ) } if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { @@ -112,13 +137,29 @@ export async function processContextsServer( ) } if (ctx.kind === 'templates' && ctx.templateId) { - return await processTemplateFromDb(ctx.templateId, ctx.label ? `@${ctx.label}` : '@') + return await processTemplateFromDb( + ctx.templateId, + userId, + ctx.label ? `@${ctx.label}` : '@', + currentWorkspaceId + ) } if (ctx.kind === 'logs' && ctx.executionId) { - return await processExecutionLogFromDb(ctx.executionId, ctx.label ? `@${ctx.label}` : '@') + return await processExecutionLogFromDb( + ctx.executionId, + userId, + ctx.label ? `@${ctx.label}` : '@', + currentWorkspaceId + ) } if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { - return await processWorkflowBlockFromDb(ctx.workflowId, ctx.blockId, ctx.label) + return await processWorkflowBlockFromDb( + ctx.workflowId, + userId, + ctx.blockId, + ctx.label, + currentWorkspaceId + ) } if (ctx.kind === 'docs') { try { @@ -203,15 +244,28 @@ function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | un async function processPastChatFromDb( chatId: string, userId: string, - tag: string + tag: string, + currentWorkspaceId?: string ): Promise { try { - const rows = await db - .select({ messages: copilotChats.messages }) - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .limit(1) - const messages = Array.isArray(rows?.[0]?.messages) ? (rows[0] as any).messages : [] + const { getAccessibleCopilotChat } = await import('@/lib/copilot/chat-lifecycle') + const chat = await getAccessibleCopilotChat(chatId, userId) + if (!chat) { + return null + } + + if (currentWorkspaceId) { + if (chat.workspaceId && chat.workspaceId !== currentWorkspaceId) { + return null + } + if (chat.workflowId) { + const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId) + if (!activeWorkflow || activeWorkflow.workspaceId !== currentWorkspaceId) { + return null + } + } + } + const messages = Array.isArray(chat.messages) ? (chat as any).messages : [] const content = messages .map((m: any) => { const role = m.role || 'user' @@ -242,10 +296,26 @@ async function processPastChatFromDb( async function processWorkflowFromDb( workflowId: string, + userId: string | undefined, tag: string, - kind: 'workflow' | 'current_workflow' = 'workflow' + kind: 'workflow' | 'current_workflow' = 'workflow', + currentWorkspaceId?: string ): Promise { try { + if (userId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return null + } + if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) { + return null + } + } + const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { logger.warn('No normalized workflow data found', { workflowId }) @@ -315,13 +385,24 @@ async function processPastChatViaApi(chatId: string, tag?: string) { async function processKnowledgeFromDb( knowledgeBaseId: string, + userId: string | undefined, tag: string, - workspaceId?: string + currentWorkspaceId?: string ): Promise { try { + if (userId) { + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, userId) + if (!accessCheck.hasAccess) { + return null + } + if (currentWorkspaceId && accessCheck.knowledgeBase?.workspaceId !== currentWorkspaceId) { + return null + } + } + const conditions = [eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)] - if (workspaceId) { - conditions.push(eq(knowledgeBase.workspaceId, workspaceId)) + if (currentWorkspaceId) { + conditions.push(eq(knowledgeBase.workspaceId, currentWorkspaceId)) } const kbRows = await db .select({ @@ -339,7 +420,14 @@ async function processKnowledgeFromDb( const docRows = await db .select({ filename: document.filename }) .from(document) - .where(and(eq(document.knowledgeBaseId, knowledgeBaseId), isNull(document.deletedAt))) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) .limit(20) const sampleDocuments = docRows.map((d: any) => d.filename).filter(Boolean) @@ -435,9 +523,25 @@ async function processBlockMetadata( async function processTemplateFromDb( templateId: string, - tag: string + userId: string | undefined, + tag: string, + currentWorkspaceId?: string ): Promise { try { + const access = await canAccessTemplate(templateId, userId) + if (!access.allowed) { + return null + } + + if (currentWorkspaceId && access.template?.workflowId) { + const workflowRecord = await getActiveWorkflowRecord(access.template.workflowId) + if (!workflowRecord || workflowRecord.workspaceId !== currentWorkspaceId) { + return null + } + } else if (currentWorkspaceId) { + return null + } + const rows = await db .select({ id: templates.id, @@ -469,10 +573,26 @@ async function processTemplateFromDb( async function processWorkflowBlockFromDb( workflowId: string, + userId: string | undefined, blockId: string, - label?: string + label?: string, + currentWorkspaceId?: string ): Promise { try { + if (userId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return null + } + if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) { + return null + } + } + const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) return null const block = (normalized.blocks as any)[blockId] @@ -493,7 +613,9 @@ async function processWorkflowBlockFromDb( async function processExecutionLogFromDb( executionId: string, - tag: string + userId: string | undefined, + tag: string, + currentWorkspaceId?: string ): Promise { try { const { workflowExecutionLogs, workflow } = await import('@sim/db/schema') @@ -520,6 +642,20 @@ async function processExecutionLogFromDb( const log = rows?.[0] as any if (!log) return null + if (userId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: log.workflowId, + userId, + action: 'read', + }) + if (!authorization.allowed) { + return null + } + if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) { + return null + } + } + const summary = { id: log.id, workflowId: log.workflowId, @@ -565,12 +701,17 @@ export async function resolveActiveResourceContext( try { switch (resourceType) { case 'workflow': { - const ctx = await processWorkflowFromDb(resourceId, '@active_resource') + const ctx = await processWorkflowFromDb(resourceId, undefined, '@active_resource') if (!ctx) return null return { type: 'active_resource', tag: '@active_resource', content: ctx.content } } case 'knowledgebase': { - const ctx = await processKnowledgeFromDb(resourceId, '@active_resource', workspaceId) + const ctx = await processKnowledgeFromDb( + resourceId, + undefined, + '@active_resource', + workspaceId + ) if (!ctx) return null return { type: 'active_resource', tag: '@active_resource', content: ctx.content } } diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index dd5b55916cb..001b437c9c8 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -557,6 +557,12 @@ export const knowledgeBaseServerTool: BaseServerTool r.id) break } @@ -96,7 +94,14 @@ export async function validateSelectorIds( const results = await db .select({ id: document.id }) .from(document) - .where(and(inArray(document.id, idsArray), isNull(document.deletedAt))) + .where( + and( + inArray(document.id, idsArray), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) existingIds = results.map((r) => r.id) break } diff --git a/apps/sim/lib/copilot/vfs/index.ts b/apps/sim/lib/copilot/vfs/index.ts index 70a8d5e6206..05ba195a1d2 100644 --- a/apps/sim/lib/copilot/vfs/index.ts +++ b/apps/sim/lib/copilot/vfs/index.ts @@ -1,3 +1,5 @@ +export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' +export { readFileRecord } from '@/lib/copilot/vfs/file-reader' export type { DirEntry, GrepCountEntry, @@ -6,8 +8,6 @@ export type { GrepOutputMode, ReadResult, } from '@/lib/copilot/vfs/operations' -export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' -export { readFileRecord } from '@/lib/copilot/vfs/file-reader' export { getOrMaterializeVFS, sanitizeName, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 98046be7658..85a34bfe40e 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -17,6 +17,7 @@ import { import { createLogger } from '@sim/logger' import { and, desc, eq, isNull, ne } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' +import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import type { DirEntry, GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' import type { DeploymentData } from '@/lib/copilot/vfs/serializers' @@ -56,14 +57,17 @@ import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getKnowledgeBases } from '@/lib/knowledge/service' import { listTables } from '@/lib/table/service' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { readFileRecord, type FileReadResult } from '@/lib/copilot/vfs/file-reader' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { listSkills } from '@/lib/workflows/skills/operations' import { listWorkflows } from '@/lib/workflows/utils' -import { getUsersWithPermissions, getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + getUsersWithPermissions, + getWorkspaceWithOwner, +} from '@/lib/workspaces/permissions/utils' import { getAllBlocks } from '@/blocks/registry' import { CONNECTOR_REGISTRY } from '@/connectors/registry' import { tools as toolRegistry } from '@/tools/registry' @@ -547,7 +551,14 @@ export class WorkspaceVFS { uploadedAt: document.uploadedAt, }) .from(document) - .where(and(eq(document.knowledgeBaseId, kb.id), isNull(document.deletedAt))) + .where( + and( + eq(document.knowledgeBaseId, kb.id), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) if (docRows.length > 0) { this.files.set(`${prefix}documents.json`, serializeDocuments(docRows)) @@ -578,6 +589,7 @@ export class WorkspaceVFS { .where( and( eq(knowledgeConnector.knowledgeBaseId, kb.id), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -1004,6 +1016,7 @@ export class WorkspaceVFS { and( eq(workflowSchedule.sourceWorkspaceId, workspaceId), eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt), ne(workflowSchedule.status, 'completed') ) ) @@ -1153,6 +1166,7 @@ export async function getOrMaterializeVFS( workspaceId: string, userId: string ): Promise { + await assertActiveWorkspaceAccess(workspaceId, userId) const vfs = new WorkspaceVFS() await vfs.materialize(workspaceId, userId) return vfs diff --git a/apps/sim/lib/copilot/workspace-context.ts b/apps/sim/lib/copilot/workspace-context.ts index e05eac15e82..b72189f6ce9 100644 --- a/apps/sim/lib/copilot/workspace-context.ts +++ b/apps/sim/lib/copilot/workspace-context.ts @@ -8,7 +8,6 @@ import { userTableRows, workflow, workflowSchedule, - workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, desc, eq, inArray, isNull } from 'drizzle-orm' @@ -16,7 +15,11 @@ import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkills } from '@/lib/workflows/skills/operations' -import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' +import { + assertActiveWorkspaceAccess, + getUsersWithPermissions, + getWorkspaceWithOwner, +} from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceContext') @@ -199,8 +202,13 @@ export async function generateWorkspaceContext( userId: string ): Promise { try { + await assertActiveWorkspaceAccess(workspaceId, userId) + const wsRow = await getWorkspaceWithOwner(workspaceId) + if (!wsRow) { + return '## Workspace\n(unavailable)' + } + const [ - wsRow, members, workflows, kbs, @@ -213,13 +221,6 @@ export async function generateWorkspaceContext( skillRows, jobRows, ] = await Promise.all([ - db - .select({ id: workspace.id, name: workspace.name, ownerId: workspace.ownerId }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - .then((rows) => rows[0] ?? null), - getUsersWithPermissions(workspaceId), db @@ -231,7 +232,7 @@ export async function generateWorkspaceContext( lastRunAt: workflow.lastRunAt, }) .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)), + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), db .select({ @@ -249,7 +250,12 @@ export async function generateWorkspaceContext( description: userTableDefinitions.description, }) .from(userTableDefinitions) - .where(eq(userTableDefinitions.workspaceId, workspaceId)), + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ), listWorkspaceFiles(workspaceId), @@ -300,7 +306,8 @@ export async function generateWorkspaceContext( .where( and( eq(workflowSchedule.sourceWorkspaceId, workspaceId), - eq(workflowSchedule.sourceType, 'job') + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt) ) ), ]) @@ -330,6 +337,7 @@ export async function generateWorkspaceContext( .where( and( inArray(knowledgeConnector.knowledgeBaseId, kbIds), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index c51a654ca9c..5f62d60f22a 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { credential, credentialMember, permissions, workspace } from '@sim/db/schema' -import { and, eq, inArray, notInArray } from 'drizzle-orm' +import { and, eq, inArray, isNull, notInArray } from 'drizzle-orm' interface AccessibleEnvCredential { type: 'env_workspace' | 'env_personal' @@ -45,8 +45,11 @@ export async function getUserWorkspaceIds(userId: string): Promise { workspace, and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspace.id)) ) - .where(eq(permissions.userId, userId)), - db.select({ workspaceId: workspace.id }).from(workspace).where(eq(workspace.ownerId, userId)), + .where(and(eq(permissions.userId, userId), isNull(workspace.archivedAt))), + db + .select({ workspaceId: workspace.id }) + .from(workspace) + .where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt))), ]) const workspaceIds = new Set(permissionRows.map((row) => row.workspaceId)) diff --git a/apps/sim/lib/execution/preprocessing.ts b/apps/sim/lib/execution/preprocessing.ts index 90bbfcdfd34..dbc209b15f6 100644 --- a/apps/sim/lib/execution/preprocessing.ts +++ b/apps/sim/lib/execution/preprocessing.ts @@ -1,7 +1,5 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' +import type { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' @@ -9,6 +7,7 @@ import { getExecutionTimeout } from '@/lib/core/execution-limits' import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter' import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { getActiveWorkflowRecord } from '@/lib/workflows/active-context' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import type { CoreTriggerType } from '@/stores/logs/filters/types' @@ -110,9 +109,9 @@ export async function preprocessExecution( let workflowRecord: WorkflowRecord | null = prefetchedWorkflowRecord ?? null if (!workflowRecord) { try { - const records = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1) + workflowRecord = await getActiveWorkflowRecord(workflowId) - if (records.length === 0) { + if (!workflowRecord) { logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) await logPreprocessingError({ @@ -136,8 +135,6 @@ export async function preprocessExecution( }, } } - - workflowRecord = records[0] } catch (error) { logger.error(`[${requestId}] Error fetching workflow`, { error, workflowId }) @@ -161,6 +158,30 @@ export async function preprocessExecution( }, } } + } else if (workflowRecord.archivedAt) { + logger.warn(`[${requestId}] Prefetched workflow is archived: ${workflowId}`) + return { + success: false, + error: { + message: 'Workflow not found', + statusCode: 404, + logCreated: false, + }, + } + } else { + const activeWorkflow = await getActiveWorkflowRecord(workflowId) + if (!activeWorkflow) { + logger.warn(`[${requestId}] Workflow archived before execution started: ${workflowId}`) + return { + success: false, + error: { + message: 'Workflow not found', + statusCode: 404, + logCreated: false, + }, + } + } + workflowRecord = activeWorkflow } const workspaceId = workflowRecord.workspaceId || providedWorkspaceId || '' diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index a40a8e990ee..c4aef86d270 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -1,8 +1,8 @@ import { createHash, randomUUID } from 'crypto' import { db } from '@sim/db' -import { document, embedding } from '@sim/db/schema' +import { document, embedding, knowledgeBase } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, ilike, inArray, sql } from 'drizzle-orm' +import { and, asc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm' import type { BatchOperationResult, ChunkData, @@ -108,6 +108,25 @@ export async function createChunk( // Use transaction to atomically get next index and insert chunk const newChunk = await db.transaction(async (tx) => { + const activeDocument = await tx + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(document.id, documentId), + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.archivedAt), + isNull(document.deletedAt), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1) + + if (activeDocument.length === 0) { + throw new Error('Document not found') + } + // Get the next chunk index atomically within the transaction const lastChunk = await tx .select({ chunkIndex: embedding.chunkIndex }) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts index b7e9f7fece5..a3edab14251 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.test.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.test.ts @@ -21,6 +21,7 @@ vi.mock('drizzle-orm', () => ({ })) vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: vi.fn() })) vi.mock('@/lib/knowledge/documents/service', () => ({ + hardDeleteDocuments: vi.fn(), isTriggerAvailable: vi.fn(), processDocumentAsync: vi.fn(), })) diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts index 07471624f9a..3ec619e723a 100644 --- a/apps/sim/lib/knowledge/connectors/sync-engine.ts +++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts @@ -6,12 +6,17 @@ import { knowledgeConnectorSyncLog, } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, ne } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne, sql } from 'drizzle-orm' import { decryptApiKey } from '@/lib/api-key/crypto' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { isTriggerAvailable, processDocumentAsync } from '@/lib/knowledge/documents/service' +import { + hardDeleteDocuments, + isTriggerAvailable, + processDocumentAsync, +} from '@/lib/knowledge/documents/service' import { StorageService } from '@/lib/uploads' import { deleteFile } from '@/lib/uploads/core/storage-service' +import { extractStorageKey } from '@/lib/uploads/utils/file-utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { knowledgeConnectorSync } from '@/background/knowledge-connector-sync' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -33,6 +38,7 @@ class ConnectorDeletedException extends Error { const SYNC_BATCH_SIZE = 5 const MAX_PAGES = 500 +type KnowledgeBaseLockingTx = Pick type DocOp = | { type: 'add'; extDoc: ExternalDocument } @@ -40,13 +46,37 @@ type DocOp = async function isConnectorDeleted(connectorId: string): Promise { const rows = await db - .select({ deletedAt: knowledgeConnector.deletedAt }) + .select({ archivedAt: knowledgeConnector.archivedAt, deletedAt: knowledgeConnector.deletedAt }) .from(knowledgeConnector) .where(eq(knowledgeConnector.id, connectorId)) .limit(1) + return rows.length === 0 || rows[0].archivedAt !== null || rows[0].deletedAt !== null +} + +async function isKnowledgeBaseDeleted(knowledgeBaseId: string): Promise { + const rows = await db + .select({ deletedAt: knowledgeBase.deletedAt }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + .limit(1) return rows.length === 0 || rows[0].deletedAt !== null } +async function isKnowledgeBaseActiveInTx( + tx: KnowledgeBaseLockingTx, + knowledgeBaseId: string +): Promise { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + + const rows = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) + + return rows.length > 0 +} + function calculateNextSyncTime(syncIntervalMinutes: number): Date | null { if (syncIntervalMinutes <= 0) return null const now = Date.now() @@ -178,7 +208,13 @@ export async function executeSync( const connectorRows = await db .select() .from(knowledgeConnector) - .where(and(eq(knowledgeConnector.id, connectorId), isNull(knowledgeConnector.deletedAt))) + .where( + and( + eq(knowledgeConnector.id, connectorId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) .limit(1) if (connectorRows.length === 0) { @@ -195,7 +231,7 @@ export async function executeSync( const kbRows = await db .select({ userId: knowledgeBase.userId }) .from(knowledgeBase) - .where(eq(knowledgeBase.id, connector.knowledgeBaseId)) + .where(and(eq(knowledgeBase.id, connector.knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (kbRows.length === 0) { @@ -218,6 +254,7 @@ export async function executeSync( and( eq(knowledgeConnector.id, connectorId), ne(knowledgeConnector.status, 'syncing'), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -286,7 +323,13 @@ export async function executeSync( contentHash: document.contentHash, }) .from(document) - .where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt))), + .where( + and( + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ), db .select({ externalId: document.externalId }) .from(document) @@ -294,6 +337,7 @@ export async function executeSync( and( eq(document.connectorId, connectorId), eq(document.userExcluded, true), + isNull(document.archivedAt), isNull(document.deletedAt) ) ), @@ -320,7 +364,13 @@ export async function executeSync( consecutiveFailures: 0, updatedAt: now, }) - .where(and(eq(knowledgeConnector.id, connectorId), isNull(knowledgeConnector.deletedAt))) + .where( + and( + eq(knowledgeConnector.id, connectorId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) return result } @@ -362,6 +412,9 @@ export async function executeSync( if (await isConnectorDeleted(connectorId)) { throw new ConnectorDeletedException(connectorId) } + if (await isKnowledgeBaseDeleted(connector.knowledgeBaseId)) { + throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`) + } const batch = pendingOps.slice(i, i + SYNC_BATCH_SIZE) const settled = await Promise.allSettled( @@ -410,10 +463,7 @@ export async function executeSync( .map((d) => d.id) if (removedIds.length > 0) { - await db - .update(document) - .set({ deletedAt: new Date() }) - .where(inArray(document.id, removedIds)) + await hardDeleteDocuments(removedIds, syncLogId) result.docsDeleted += removedIds.length } } @@ -422,6 +472,9 @@ export async function executeSync( if (await isConnectorDeleted(connectorId)) { throw new ConnectorDeletedException(connectorId) } + if (await isKnowledgeBaseDeleted(connector.knowledgeBaseId)) { + throw new Error(`Knowledge base ${connector.knowledgeBaseId} was deleted during sync`) + } // Retry stuck documents that failed or never completed processing const stuckDocs = await db @@ -436,6 +489,8 @@ export async function executeSync( and( eq(document.connectorId, connectorId), inArray(document.processingStatus, ['pending', 'failed']), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -476,7 +531,13 @@ export async function executeSync( consecutiveFailures: 0, updatedAt: now, }) - .where(and(eq(knowledgeConnector.id, connectorId), isNull(knowledgeConnector.deletedAt))) + .where( + and( + eq(knowledgeConnector.id, connectorId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) logger.info('Sync completed', { connectorId, ...result }) return result @@ -485,11 +546,21 @@ export async function executeSync( logger.info('Connector deleted during sync, cleaning up', { connectorId }) try { - const cleanupTime = new Date() - await db - .update(document) - .set({ deletedAt: cleanupTime }) - .where(and(eq(document.connectorId, connectorId), isNull(document.deletedAt))) + const connectorDocs = await db + .select({ id: document.id }) + .from(document) + .where( + and( + eq(document.connectorId, connectorId), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + + await hardDeleteDocuments( + connectorDocs.map((doc) => doc.id), + syncLogId + ) await completeSyncLog(syncLogId, 'failed', result, 'Connector deleted during sync') } catch (cleanupError) { @@ -524,7 +595,13 @@ export async function executeSync( consecutiveFailures: failures, updatedAt: now, }) - .where(and(eq(knowledgeConnector.id, connectorId), isNull(knowledgeConnector.deletedAt))) + .where( + and( + eq(knowledgeConnector.id, connectorId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) } catch (recoveryError) { logger.error('Failed to record sync failure', { connectorId, @@ -548,6 +625,9 @@ async function addDocument( extDoc: ExternalDocument, sourceConfig?: Record ): Promise { + if (await isKnowledgeBaseDeleted(knowledgeBaseId)) { + throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`) + } const documentId = crypto.randomUUID() const contentBuffer = Buffer.from(extDoc.content, 'utf-8') const safeTitle = extDoc.title.replace(/[^a-zA-Z0-9.-]/g, '_') @@ -570,25 +650,41 @@ async function addDocument( const processingFilename = `${safeTitle}.txt` - await db.insert(document).values({ - id: documentId, - knowledgeBaseId, - filename: extDoc.title, - fileUrl, - fileSize: contentBuffer.length, - mimeType: 'text/plain', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - processingStatus: 'pending', - enabled: true, - connectorId, - externalId: extDoc.externalId, - contentHash: extDoc.contentHash, - sourceUrl: extDoc.sourceUrl ?? null, - ...tagValues, - uploadedAt: new Date(), - }) + try { + await db.transaction(async (tx) => { + const isActive = await isKnowledgeBaseActiveInTx(tx, knowledgeBaseId) + if (!isActive) { + throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`) + } + + await tx.insert(document).values({ + id: documentId, + knowledgeBaseId, + filename: extDoc.title, + fileUrl, + fileSize: contentBuffer.length, + mimeType: 'text/plain', + chunkCount: 0, + tokenCount: 0, + characterCount: 0, + processingStatus: 'pending', + enabled: true, + connectorId, + externalId: extDoc.externalId, + contentHash: extDoc.contentHash, + sourceUrl: extDoc.sourceUrl ?? null, + ...tagValues, + uploadedAt: new Date(), + }) + }) + } catch (error) { + const urlPath = new URL(fileUrl, 'http://localhost').pathname + const storageKey = extractStorageKey(urlPath) + if (storageKey && storageKey !== urlPath) { + await deleteFile({ key: storageKey, context: 'knowledge-base' }).catch(() => undefined) + } + throw error + } processDocumentAsync( knowledgeBaseId, @@ -621,6 +717,9 @@ async function updateDocument( extDoc: ExternalDocument, sourceConfig?: Record ): Promise { + if (await isKnowledgeBaseDeleted(knowledgeBaseId)) { + throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`) + } // Fetch old file URL before uploading replacement const existingRows = await db .select({ fileUrl: document.fileUrl }) @@ -650,25 +749,53 @@ async function updateDocument( const processingFilename = `${safeTitle}.txt` - await db - .update(document) - .set({ - filename: extDoc.title, - fileUrl, - fileSize: contentBuffer.length, - contentHash: extDoc.contentHash, - sourceUrl: extDoc.sourceUrl ?? null, - ...tagValues, - processingStatus: 'pending', - uploadedAt: new Date(), + try { + await db.transaction(async (tx) => { + const isActive = await isKnowledgeBaseActiveInTx(tx, knowledgeBaseId) + if (!isActive) { + throw new Error(`Knowledge base ${knowledgeBaseId} is deleted`) + } + + await tx + .update(document) + .set({ + filename: extDoc.title, + fileUrl, + fileSize: contentBuffer.length, + contentHash: extDoc.contentHash, + sourceUrl: extDoc.sourceUrl ?? null, + ...tagValues, + processingStatus: 'pending', + uploadedAt: new Date(), + }) + .where( + and( + eq(document.id, existingDocId), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + .returning({ id: document.id }) + .then((rows) => { + if (rows.length === 0) { + throw new Error(`Document ${existingDocId} is no longer active`) + } + }) }) - .where(eq(document.id, existingDocId)) + } catch (error) { + const urlPath = new URL(fileUrl, 'http://localhost').pathname + const storageKey = extractStorageKey(urlPath) + if (storageKey && storageKey !== urlPath) { + await deleteFile({ key: storageKey, context: 'knowledge-base' }).catch(() => undefined) + } + throw error + } // Clean up old storage file if (oldFileUrl) { try { const urlPath = new URL(oldFileUrl, 'http://localhost').pathname - const storageKey = urlPath.replace(/^\/api\/uploads\//, '') + const storageKey = extractStorageKey(urlPath) if (storageKey && storageKey !== urlPath) { await deleteFile({ key: storageKey, context: 'knowledge-base' }) } diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts index 570d4b2f112..2d607c41da5 100644 --- a/apps/sim/lib/knowledge/documents/service.ts +++ b/apps/sim/lib/knowledge/documents/service.ts @@ -17,6 +17,7 @@ import { gt, gte, inArray, + isNotNull, isNull, lt, lte, @@ -38,6 +39,8 @@ import { validateTagValue, } from '@/lib/knowledge/tags/utils' import type { ProcessedDocumentTags } from '@/lib/knowledge/types' +import { deleteFile } from '@/lib/uploads/core/storage-service' +import { extractStorageKey } from '@/lib/uploads/utils/file-utils' import type { DocumentProcessingPayload } from '@/background/knowledge-processing' const logger = createLogger('DocumentService') @@ -432,7 +435,7 @@ export async function processDocumentAsync( chunkingConfig: knowledgeBase.chunkingConfig, }) .from(knowledgeBase) - .where(eq(knowledgeBase.id, knowledgeBaseId)) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .limit(1) if (kb.length === 0) { @@ -446,7 +449,9 @@ export async function processDocumentAsync( processingStartedAt: new Date(), processingError: null, }) - .where(eq(document.id, documentId)) + .where( + and(eq(document.id, documentId), isNull(document.archivedAt), isNull(document.deletedAt)) + ) logger.info(`[${documentId}] Status updated to 'processing', starting document processor`) @@ -526,7 +531,13 @@ export async function processDocumentAsync( boolean3: document.boolean3, }) .from(document) - .where(eq(document.id, documentId)) + .where( + and( + eq(document.id, documentId), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) .limit(1) const documentTags = documentRecord[0] || {} @@ -572,6 +583,24 @@ export async function processDocumentAsync( })) await db.transaction(async (tx) => { + const activeDocument = await tx + .select({ id: document.id }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + eq(document.id, documentId), + isNull(document.archivedAt), + isNull(document.deletedAt), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1) + + if (activeDocument.length === 0) { + return + } + if (embeddingRecords.length > 0) { await tx.delete(embedding).where(eq(embedding.documentId, documentId)) @@ -693,17 +722,19 @@ export async function createDocumentRecords( knowledgeBaseId: string, requestId: string ): Promise { - const kb = await db - .select({ userId: knowledgeBase.userId }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - .limit(1) + return await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } + const kb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) + + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } - return await db.transaction(async (tx) => { const now = new Date() const documentRecords = [] const returnData: DocumentData[] = [] @@ -1008,6 +1039,8 @@ export async function getDocuments( const whereConditions: (SQL | undefined)[] = [ eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), ] @@ -1207,16 +1240,6 @@ export async function createSingleDocument( tag6: string | null tag7: string | null }> { - const kb = await db - .select({ userId: knowledgeBase.userId }) - .from(knowledgeBase) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - .limit(1) - - if (kb.length === 0) { - throw new Error('Knowledge base not found') - } - const documentId = randomUUID() const now = new Date() @@ -1274,13 +1297,26 @@ export async function createSingleDocument( ...processedTags, } - await db.insert(document).values(newDocument) + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) - await db - .update(knowledgeBase) - .set({ updatedAt: now }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) + const kb = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + .limit(1) + if (kb.length === 0) { + throw new Error('Knowledge base not found') + } + + await tx.insert(document).values(newDocument) + + await tx + .update(knowledgeBase) + .set({ updatedAt: now }) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + }) logger.info(`[${requestId}] Document created: ${documentId} in knowledge base ${knowledgeBaseId}`) return newDocument as { @@ -1337,6 +1373,8 @@ export async function bulkDocumentOperation( and( eq(document.knowledgeBaseId, knowledgeBaseId), inArray(document.id, documentIds), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -1359,20 +1397,9 @@ export async function bulkDocumentOperation( }> if (operation === 'delete') { - updateResult = await db - .update(document) - .set({ - deletedAt: new Date(), - userExcluded: sql`CASE WHEN ${document.connectorId} IS NOT NULL THEN true ELSE ${document.userExcluded} END`, - }) - .where( - and( - eq(document.knowledgeBaseId, knowledgeBaseId), - inArray(document.id, documentIds), - isNull(document.deletedAt) - ) - ) - .returning({ id: document.id, deletedAt: document.deletedAt }) + const deletedIds = documentsToUpdate.map((doc) => doc.id) + const deletedCount = await deleteDocumentsByLifecyclePolicy(deletedIds, requestId) + updateResult = deletedIds.slice(0, deletedCount).map((id) => ({ id })) } else { const enabled = operation === 'enable' @@ -1385,6 +1412,8 @@ export async function bulkDocumentOperation( and( eq(document.knowledgeBaseId, knowledgeBaseId), inArray(document.id, documentIds), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt) ) ) @@ -1427,6 +1456,8 @@ export async function bulkDocumentOperationByFilter( const whereConditions = [ eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), ] @@ -1443,14 +1474,14 @@ export async function bulkDocumentOperationByFilter( }> if (operation === 'delete') { - updateResult = await db - .update(document) - .set({ - deletedAt: new Date(), - userExcluded: sql`CASE WHEN ${document.connectorId} IS NOT NULL THEN true ELSE ${document.userExcluded} END`, - }) + const matchingDocs = await db + .select({ id: document.id }) + .from(document) .where(and(...whereConditions)) - .returning({ id: document.id, deletedAt: document.deletedAt }) + + const deletedIds = matchingDocs.map((doc) => doc.id) + const deletedCount = await deleteDocumentsByLifecyclePolicy(deletedIds, requestId) + updateResult = deletedIds.slice(0, deletedCount).map((id) => ({ id })) } else { const enabled = operation === 'enable' @@ -1817,35 +1848,144 @@ export async function updateDocument( } } -/** - * Soft delete a document. - * For connector-sourced documents, also sets userExcluded so the sync engine - * will not re-import the document on future syncs. - */ -export async function deleteDocument( - documentId: string, +function getKnowledgeBaseStorageKey(fileUrl: string | null): string | null { + if (!fileUrl) { + return null + } + + try { + const urlPath = new URL(fileUrl, 'http://localhost').pathname + const storageKey = extractStorageKey(urlPath) + return storageKey !== urlPath ? storageKey : null + } catch { + return null + } +} + +export async function deleteDocumentStorageFiles( + documentsToDelete: Array<{ id: string; fileUrl: string | null }>, requestId: string -): Promise<{ success: boolean; message: string }> { - const docs = await db - .select({ connectorId: document.connectorId }) - .from(document) - .where(eq(document.id, documentId)) - .limit(1) +): Promise { + await Promise.allSettled( + documentsToDelete.map(async (doc) => { + const storageKey = getKnowledgeBaseStorageKey(doc.fileUrl) + if (!storageKey) { + return + } - const isConnectorDoc = docs.length > 0 && docs[0].connectorId !== null + try { + await deleteFile({ key: storageKey, context: 'knowledge-base' }) + } catch (error) { + logger.warn(`[${requestId}] Failed to delete document storage file`, { + documentId: doc.id, + error: error instanceof Error ? error.message : String(error), + }) + } + }) + ) +} - await db +async function excludeConnectorDocuments( + documentIds: string[], + requestId: string +): Promise { + const ids = [...new Set(documentIds)] + if (ids.length === 0) { + return 0 + } + + const updated = await db .update(document) .set({ - deletedAt: new Date(), - ...(isConnectorDoc ? { userExcluded: true } : {}), + userExcluded: true, + enabled: false, }) - .where(eq(document.id, documentId)) + .where(and(inArray(document.id, ids), isNotNull(document.connectorId))) + .returning({ id: document.id }) + + if (updated.length > 0) { + logger.info(`[${requestId}] Excluded ${updated.length} connector-backed document(s)`, { + documentIds: updated.map((doc) => doc.id), + }) + } + + return updated.length +} + +export async function deleteDocumentsByLifecyclePolicy( + documentIds: string[], + requestId: string +): Promise { + const ids = [...new Set(documentIds)] + if (ids.length === 0) { + return 0 + } - logger.info(`[${requestId}] Document deleted: ${documentId}`, { - userExcluded: isConnectorDoc, + const docs = await db + .select({ + id: document.id, + connectorId: document.connectorId, + }) + .from(document) + .where(inArray(document.id, ids)) + + const connectorBackedIds = docs.filter((doc) => doc.connectorId !== null).map((doc) => doc.id) + const hardDeleteIds = docs.filter((doc) => doc.connectorId === null).map((doc) => doc.id) + + const [excludedCount, hardDeletedCount] = await Promise.all([ + excludeConnectorDocuments(connectorBackedIds, requestId), + hardDeleteDocuments(hardDeleteIds, requestId), + ]) + + return excludedCount + hardDeletedCount +} + +export async function hardDeleteDocuments( + documentIds: string[], + requestId: string +): Promise { + const ids = [...new Set(documentIds)] + if (ids.length === 0) { + return 0 + } + + const documentsToDelete = await db + .select({ + id: document.id, + fileUrl: document.fileUrl, + }) + .from(document) + .where(inArray(document.id, ids)) + + if (documentsToDelete.length === 0) { + return 0 + } + + const existingIds = documentsToDelete.map((doc) => doc.id) + + await db.transaction(async (tx) => { + await tx.delete(embedding).where(inArray(embedding.documentId, existingIds)) + await tx.delete(document).where(inArray(document.id, existingIds)) }) + await deleteDocumentStorageFiles(documentsToDelete, requestId) + + logger.info(`[${requestId}] Hard deleted ${existingIds.length} documents`, { + documentIds: existingIds, + }) + + return existingIds.length +} + +/** + * Hard delete a document. + */ +export async function deleteDocument( + documentId: string, + requestId: string +): Promise<{ success: boolean; message: string }> { + await deleteDocumentsByLifecyclePolicy([documentId], requestId) + return { success: true, message: 'Document deleted successfully', diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts index 1823e675524..58af6e310da 100644 --- a/apps/sim/lib/knowledge/service.ts +++ b/apps/sim/lib/knowledge/service.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' -import { document, knowledgeBase, knowledgeConnector, permissions } from '@sim/db/schema' +import { document, knowledgeBase, knowledgeConnector, permissions, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, count, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' import type { @@ -12,13 +12,23 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('KnowledgeBaseService') +export type KnowledgeBaseScope = 'active' | 'archived' | 'all' + /** * Get knowledge bases that a user can access */ export async function getKnowledgeBases( userId: string, - workspaceId?: string | null + workspaceId?: string | null, + scope: KnowledgeBaseScope = 'active' ): Promise { + const scopeCondition = + scope === 'all' + ? undefined + : scope === 'archived' + ? sql`${knowledgeBase.deletedAt} IS NOT NULL` + : isNull(knowledgeBase.deletedAt) + const knowledgeBasesWithCounts = await db .select({ id: knowledgeBase.id, @@ -37,7 +47,12 @@ export async function getKnowledgeBases( .from(knowledgeBase) .leftJoin( document, - and(eq(document.knowledgeBaseId, knowledgeBase.id), isNull(document.deletedAt)) + and( + eq(document.knowledgeBaseId, knowledgeBase.id), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) ) .leftJoin( permissions, @@ -47,14 +62,19 @@ export async function getKnowledgeBases( eq(permissions.userId, userId) ) ) + .leftJoin(workspace, eq(knowledgeBase.workspaceId, workspace.id)) .where( and( - isNull(knowledgeBase.deletedAt), + scopeCondition, workspaceId ? // When filtering by workspace or( // Knowledge bases belonging to the specified workspace (user must have workspace permissions) - and(eq(knowledgeBase.workspaceId, workspaceId), isNotNull(permissions.userId)), + and( + eq(knowledgeBase.workspaceId, workspaceId), + isNotNull(permissions.userId), + isNull(workspace.archivedAt) + ), // Fallback: User-owned knowledge bases without workspace (legacy) and(eq(knowledgeBase.userId, userId), isNull(knowledgeBase.workspaceId)) ) @@ -63,7 +83,7 @@ export async function getKnowledgeBases( // User owns the knowledge base directly eq(knowledgeBase.userId, userId), // User has permissions on the knowledge base's workspace - isNotNull(permissions.userId) + and(isNotNull(permissions.userId), isNull(workspace.archivedAt)) ) ) ) @@ -83,6 +103,7 @@ export async function getKnowledgeBases( .where( and( inArray(knowledgeConnector.knowledgeBaseId, kbIds), + isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) @@ -199,7 +220,10 @@ export async function updateKnowledgeBase( updateData.embeddingDimension = 1536 } - await db.update(knowledgeBase).set(updateData).where(eq(knowledgeBase.id, knowledgeBaseId)) + await db + .update(knowledgeBase) + .set(updateData) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) const updatedKb = await db .select({ @@ -219,9 +243,14 @@ export async function updateKnowledgeBase( .from(knowledgeBase) .leftJoin( document, - and(eq(document.knowledgeBaseId, knowledgeBase.id), isNull(document.deletedAt)) + and( + eq(document.knowledgeBaseId, knowledgeBase.id), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) ) - .where(eq(knowledgeBase.id, knowledgeBaseId)) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .groupBy(knowledgeBase.id) .limit(1) @@ -263,7 +292,12 @@ export async function getKnowledgeBaseById( .from(knowledgeBase) .leftJoin( document, - and(eq(document.knowledgeBaseId, knowledgeBase.id), isNull(document.deletedAt)) + and( + eq(document.knowledgeBaseId, knowledgeBase.id), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) ) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) .groupBy(knowledgeBase.id) @@ -290,13 +324,45 @@ export async function deleteKnowledgeBase( ): Promise { const now = new Date() - await db - .update(knowledgeBase) - .set({ - deletedAt: now, - updatedAt: now, - }) - .where(eq(knowledgeBase.id, knowledgeBaseId)) + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`) + + await tx + .update(knowledgeBase) + .set({ + deletedAt: now, + updatedAt: now, + }) + .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) + + await tx + .update(document) + .set({ + archivedAt: now, + }) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + + await tx + .update(knowledgeConnector) + .set({ + archivedAt: now, + status: 'paused', + updatedAt: now, + }) + .where( + and( + eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + }) logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index c2c45fa7689..f5578e18929 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -375,6 +375,7 @@ export async function cleanupUnusedTagDefinitions( .where( and( eq(document.knowledgeBaseId, knowledgeBaseId), + isNull(document.archivedAt), isNull(document.deletedAt), sql`${sql.raw(tagSlot)} IS NOT NULL` ) @@ -383,8 +384,14 @@ export async function cleanupUnusedTagDefinitions( const chunkCountResult = await db .select({ count: sql`count(*)` }) .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) .where( - and(eq(embedding.knowledgeBaseId, knowledgeBaseId), sql`${sql.raw(tagSlot)} IS NOT NULL`) + and( + eq(embedding.knowledgeBaseId, knowledgeBaseId), + isNull(document.archivedAt), + isNull(document.deletedAt), + sql`${sql.raw(`embedding.${tagSlot}`)} IS NOT NULL` + ) ) const docCount = Number(docCountResult[0]?.count || 0) @@ -411,12 +418,37 @@ export async function deleteAllTagDefinitions( knowledgeBaseId: string, requestId: string ): Promise { - const result = await db - .delete(knowledgeBaseTagDefinitions) + const definitions = await db + .select({ id: knowledgeBaseTagDefinitions.id, tagSlot: knowledgeBaseTagDefinitions.tagSlot }) + .from(knowledgeBaseTagDefinitions) .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) - .returning({ id: knowledgeBaseTagDefinitions.id }) - const deletedCount = result.length + await db.transaction(async (tx) => { + for (const definition of definitions) { + const tagSlot = definition.tagSlot as string + validateTagSlot(tagSlot) + + await tx + .update(document) + .set({ [tagSlot]: null }) + .where( + and(eq(document.knowledgeBaseId, knowledgeBaseId), isNotNull(sql`${sql.raw(tagSlot)}`)) + ) + + await tx + .update(embedding) + .set({ [tagSlot]: null }) + .where( + and(eq(embedding.knowledgeBaseId, knowledgeBaseId), isNotNull(sql`${sql.raw(tagSlot)}`)) + ) + } + + await tx + .delete(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) + }) + + const deletedCount = definitions.length logger.info(`[${requestId}] Deleted ${deletedCount} tag definitions for KB: ${knowledgeBaseId}`) return deletedCount @@ -427,6 +459,7 @@ export async function deleteAllTagDefinitions( * This removes the definition and clears all document/chunk references */ export async function deleteTagDefinition( + knowledgeBaseId: string, tagDefinitionId: string, requestId: string ): Promise<{ tagSlot: string; displayName: string }> { @@ -438,7 +471,12 @@ export async function deleteTagDefinition( displayName: knowledgeBaseTagDefinitions.displayName, }) .from(knowledgeBaseTagDefinitions) - .where(eq(knowledgeBaseTagDefinitions.id, tagDefinitionId)) + .where( + and( + eq(knowledgeBaseTagDefinitions.id, tagDefinitionId), + eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId) + ) + ) .limit(1) if (tagDef.length === 0) { @@ -446,7 +484,7 @@ export async function deleteTagDefinition( } const definition = tagDef[0] - const knowledgeBaseId = definition.knowledgeBaseId + const definitionKnowledgeBaseId = definition.knowledgeBaseId const tagSlot = definition.tagSlot as string validateTagSlot(tagSlot) @@ -456,14 +494,20 @@ export async function deleteTagDefinition( .update(document) .set({ [tagSlot]: null }) .where( - and(eq(document.knowledgeBaseId, knowledgeBaseId), isNotNull(sql`${sql.raw(tagSlot)}`)) + and( + eq(document.knowledgeBaseId, definitionKnowledgeBaseId), + isNotNull(sql`${sql.raw(tagSlot)}`) + ) ) await tx .update(embedding) .set({ [tagSlot]: null }) .where( - and(eq(embedding.knowledgeBaseId, knowledgeBaseId), isNotNull(sql`${sql.raw(tagSlot)}`)) + and( + eq(embedding.knowledgeBaseId, definitionKnowledgeBaseId), + isNotNull(sql`${sql.raw(tagSlot)}`) + ) ) await tx @@ -600,6 +644,8 @@ export async function getTagUsage( const whereConditions = [ eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), isNotNull(sql`${sql.raw(tagSlot)}`), ] @@ -663,6 +709,8 @@ export async function getTagUsageStats( .where( and( eq(document.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), isNull(document.deletedAt), sql`${sql.raw(tagSlot)} IS NOT NULL` ) @@ -671,8 +719,15 @@ export async function getTagUsageStats( const chunkCountResult = await db .select({ count: sql`count(*)` }) .from(embedding) + .innerJoin(document, eq(embedding.documentId, document.id)) .where( - and(eq(embedding.knowledgeBaseId, knowledgeBaseId), sql`${sql.raw(tagSlot)} IS NOT NULL`) + and( + eq(embedding.knowledgeBaseId, knowledgeBaseId), + eq(document.userExcluded, false), + isNull(document.archivedAt), + isNull(document.deletedAt), + sql`${sql.raw(`embedding.${tagSlot}`)} IS NOT NULL` + ) ) stats.push({ diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index 8289919e167..17ee7bd6782 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -1,9 +1,5 @@ import { db } from '@sim/db' -import { - workflow, - workspaceNotificationDelivery, - workspaceNotificationSubscription, -} from '@sim/db/schema' +import { workspaceNotificationDelivery, workspaceNotificationSubscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, or, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' @@ -14,6 +10,7 @@ import { type AlertConfig, shouldTriggerAlert, } from '@/lib/notifications/alert-rules' +import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { executeNotificationDelivery, workspaceNotificationDeliveryTask, @@ -52,15 +49,10 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): try { if (!log.workflowId) return - const workflowData = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, log.workflowId)) - .limit(1) - - if (workflowData.length === 0 || !workflowData[0].workspaceId) return + const workflowContext = await getActiveWorkflowContext(log.workflowId) + if (!workflowContext?.workspaceId) return - const workspaceId = workflowData[0].workspaceId + const workspaceId = workflowContext.workspaceId const subscriptions = await db .select() diff --git a/apps/sim/lib/mcp/pubsub.test.ts b/apps/sim/lib/mcp/pubsub.test.ts index db8ee22d01d..a9aeb3fab05 100644 --- a/apps/sim/lib/mcp/pubsub.test.ts +++ b/apps/sim/lib/mcp/pubsub.test.ts @@ -56,7 +56,7 @@ describe('RedisMcpPubSub', () => { it('creates two Redis clients (pub and sub)', async () => { const { mcpPubSub, instances } = await setupPubSub() - expect(instances).toHaveLength(2) + expect(instances.length).toBeGreaterThanOrEqual(2) mcpPubSub.dispose() }) diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index 6942453086d..6bace2d3771 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -1,6 +1,6 @@ import { db, workflowMcpServer, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -65,7 +65,7 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise): void { const servers = await db .select({ id: workflowMcpServer.id, workspaceId: workflowMcpServer.workspaceId }) .from(workflowMcpServer) - .where(inArray(workflowMcpServer.id, uniqueServerIds)) + .where( + and(inArray(workflowMcpServer.id, uniqueServerIds), isNull(workflowMcpServer.deletedAt)) + ) for (const server of servers) { mcpPubSub.publishWorkflowToolsChanged({ diff --git a/apps/sim/lib/table/llm/wand.ts b/apps/sim/lib/table/llm/wand.ts index 37971012ed2..30c036c64ed 100644 --- a/apps/sim/lib/table/llm/wand.ts +++ b/apps/sim/lib/table/llm/wand.ts @@ -5,7 +5,7 @@ import { db } from '@sim/db' import { userTableDefinitions } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { TableSchema } from '../types' const logger = createLogger('TableWandEnricher') @@ -31,7 +31,11 @@ export async function enrichTableSchema( }) .from(userTableDefinitions) .where( - and(eq(userTableDefinitions.id, tableId), eq(userTableDefinitions.workspaceId, workspaceId)) + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) ) .limit(1) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index a3e445862c4..23344dc6e48 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -10,7 +10,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, count, eq, gt, gte, inArray, sql } from 'drizzle-orm' +import { and, count, eq, gt, gte, inArray, isNull, sql } from 'drizzle-orm' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants' import { buildFilterClause, buildSortClause } from './sql' import type { @@ -50,17 +50,27 @@ import { const logger = createLogger('TableService') +export type TableScope = 'active' | 'archived' | 'all' + /** * Gets a table by ID with full details. * * @param tableId - Table ID to fetch * @returns Table definition or null if not found */ -export async function getTableById(tableId: string): Promise { +export async function getTableById( + tableId: string, + options?: { includeArchived?: boolean } +): Promise { + const { includeArchived = false } = options ?? {} const results = await db .select() .from(userTableDefinitions) - .where(eq(userTableDefinitions.id, tableId)) + .where( + includeArchived + ? eq(userTableDefinitions.id, tableId) + : and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt)) + ) .limit(1) if (results.length === 0) return null @@ -76,6 +86,7 @@ export async function getTableById(tableId: string): Promise { const [result] = await db .select({ count: count() }) .from(userTableDefinitions) - .where(eq(userTableDefinitions.workspaceId, workspaceId)) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) return result.count } -export async function listTables(workspaceId: string): Promise { +export async function listTables( + workspaceId: string, + options?: { scope?: TableScope } +): Promise { + const { scope = 'active' } = options ?? {} const tables = await db .select({ id: userTableDefinitions.id, @@ -106,13 +126,26 @@ export async function listTables(workspaceId: string): Promise`coalesce(${count(userTableRows.id)}, 0)`.mapWith(Number), }) .from(userTableDefinitions) .leftJoin(userTableRows, eq(userTableRows.tableId, userTableDefinitions.id)) - .where(eq(userTableDefinitions.workspaceId, workspaceId)) + .where( + scope === 'all' + ? eq(userTableDefinitions.workspaceId, workspaceId) + : scope === 'archived' + ? and( + eq(userTableDefinitions.workspaceId, workspaceId), + sql`${userTableDefinitions.archivedAt} IS NOT NULL` + ) + : and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) .groupBy(userTableDefinitions.id) .orderBy(userTableDefinitions.createdAt) @@ -126,6 +159,7 @@ export async function listTables(workspaceId: string): Promise= maxTables) { throw new Error(`Workspace has reached maximum table limit (${maxTables})`) @@ -194,7 +234,8 @@ export async function createTable( .where( and( eq(userTableDefinitions.workspaceId, data.workspaceId), - eq(userTableDefinitions.name, data.name) + eq(userTableDefinitions.name, data.name), + isNull(userTableDefinitions.archivedAt) ) ) .limit(1) @@ -232,6 +273,7 @@ export async function createTable( maxRows: newTable.maxRows, workspaceId: newTable.workspaceId, createdBy: newTable.createdBy, + archivedAt: newTable.archivedAt, createdAt: newTable.createdAt, updatedAt: newTable.updatedAt, } @@ -376,18 +418,18 @@ export async function updateTableMetadata( } /** - * Deletes a table (hard delete). + * Archives a table. * * @param tableId - Table ID to delete * @param requestId - Request ID for logging */ export async function deleteTable(tableId: string, requestId: string): Promise { - await db.transaction(async (trx) => { - await trx.delete(userTableRows).where(eq(userTableRows.tableId, tableId)) - await trx.delete(userTableDefinitions).where(eq(userTableDefinitions.id, tableId)) - }) + await db + .update(userTableDefinitions) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(userTableDefinitions.id, tableId)) - logger.info(`[${requestId}] Deleted table ${tableId}`) + logger.info(`[${requestId}] Archived table ${tableId}`) } /** diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 6ad5b3b4952..3fe8549d452 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -47,6 +47,7 @@ export interface TableDefinition { maxRows: number workspaceId: string createdBy: string + archivedAt?: Date | string | null createdAt: Date | string updatedAt: Date | string } diff --git a/apps/sim/lib/templates/permissions.ts b/apps/sim/lib/templates/permissions.ts index f49dffb5e7c..20fc2bbcbb5 100644 --- a/apps/sim/lib/templates/permissions.ts +++ b/apps/sim/lib/templates/permissions.ts @@ -142,3 +142,34 @@ export async function verifyCreatorPermission( return { hasPermission: false, error: 'Unknown creator profile type' } } + +export async function canAccessTemplate( + templateId: string, + userId?: string | null +): Promise<{ allowed: boolean; template?: typeof templates.$inferSelect }> { + const [template] = await db.select().from(templates).where(eq(templates.id, templateId)).limit(1) + + if (!template) { + return { allowed: false } + } + + if (template.status === 'approved') { + return { allowed: true, template } + } + + if (!userId) { + return { allowed: false, template } + } + + const { effectiveSuperUser } = await verifyEffectiveSuperUser(userId) + if (effectiveSuperUser) { + return { allowed: true, template } + } + + if (!template.creatorId) { + return { allowed: false, template } + } + + const { hasPermission } = await verifyCreatorPermission(userId, template.creatorId, 'member') + return { allowed: hasPermission, template } +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index d311e693730..afc5e7918a1 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -6,14 +6,13 @@ import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { checkStorageQuota, decrementStorageUsage, incrementStorageUsage, } from '@/lib/billing/storage' import { - deleteFile, downloadFile, hasCloudStorage, uploadFile, @@ -24,6 +23,8 @@ import type { UserFile } from '@/executor/types' const logger = createLogger('WorkspaceFileStorage') +export type WorkspaceFileScope = 'active' | 'archived' | 'all' + export class FileConflictError extends Error { readonly code = 'FILE_EXISTS' as const constructor(name: string) { @@ -41,6 +42,7 @@ export interface WorkspaceFileRecord { size: number type: string uploadedBy: string + deletedAt?: Date | null uploadedAt: Date } @@ -220,7 +222,8 @@ export async function fileExistsInWorkspace( and( eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.originalName, fileName), - eq(workspaceFiles.context, 'workspace') + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) ) ) .limit(1) @@ -235,13 +238,29 @@ export async function fileExistsInWorkspace( /** * List all files for a workspace */ -export async function listWorkspaceFiles(workspaceId: string): Promise { +export async function listWorkspaceFiles( + workspaceId: string, + options?: { scope?: WorkspaceFileScope } +): Promise { try { + const { scope = 'active' } = options ?? {} const files = await db .select() .from(workspaceFiles) .where( - and(eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'workspace')) + scope === 'all' + ? and(eq(workspaceFiles.workspaceId, workspaceId), eq(workspaceFiles.context, 'workspace')) + : scope === 'archived' + ? and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + sql`${workspaceFiles.deletedAt} IS NOT NULL` + ) + : and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) ) .orderBy(workspaceFiles.uploadedAt) @@ -257,6 +276,7 @@ export async function listWorkspaceFiles(workspaceId: string): Promise { try { + const { includeDeleted = false } = options ?? {} const files = await db .select() .from(workspaceFiles) .where( - and( - eq(workspaceFiles.id, fileId), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') - ) + includeDeleted + ? and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace') + ) + : and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) ) .limit(1) @@ -300,6 +329,7 @@ export async function getWorkspaceFile( size: file.size, type: file.contentType, uploadedBy: file.userId, + deletedAt: file.deletedAt, uploadedAt: file.uploadedAt, } } catch (error) { @@ -465,7 +495,7 @@ export async function renameWorkspaceFile( } /** - * Delete a workspace file (both from storage and database) + * Soft delete a workspace file. */ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): Promise { logger.info(`Deleting workspace file: ${fileId}`) @@ -476,28 +506,19 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): throw new Error('File not found') } - await deleteFile({ - key: fileRecord.key, - context: 'workspace', - }) - await db - .delete(workspaceFiles) + .update(workspaceFiles) + .set({ deletedAt: new Date() }) .where( and( eq(workspaceFiles.id, fileId), eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace') + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) ) ) - try { - await decrementStorageUsage(fileRecord.uploadedBy, fileRecord.size) - } catch (storageError) { - logger.error(`Failed to update storage tracking:`, storageError) - } - - logger.info(`Successfully deleted workspace file: ${fileRecord.name}`) + logger.info(`Successfully archived workspace file: ${fileRecord.name}`) } catch (error) { logger.error(`Failed to delete workspace file ${fileId}:`, error) throw new Error( diff --git a/apps/sim/lib/uploads/server/metadata.ts b/apps/sim/lib/uploads/server/metadata.ts index eedcec15d2d..a45b8a06274 100644 --- a/apps/sim/lib/uploads/server/metadata.ts +++ b/apps/sim/lib/uploads/server/metadata.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { StorageContext } from '../shared/types' const logger = createLogger('FileMetadata') @@ -15,6 +15,7 @@ export interface FileMetadataRecord { originalName: string contentType: string size: number + deletedAt?: Date | null uploadedAt: Date } @@ -44,12 +45,47 @@ export async function insertFileMetadata( ): Promise { const { key, userId, workspaceId, context, originalName, contentType, size, id } = options - const existing = await db + const existingDeleted = await db .select() .from(workspaceFiles) .where(eq(workspaceFiles.key, key)) .limit(1) + if (existingDeleted.length > 0 && existingDeleted[0].deletedAt) { + await db + .update(workspaceFiles) + .set({ + userId, + workspaceId: workspaceId || null, + context, + originalName, + contentType, + size, + deletedAt: null, + uploadedAt: new Date(), + }) + .where(eq(workspaceFiles.id, existingDeleted[0].id)) + + return { + id: existingDeleted[0].id, + key, + userId, + workspaceId: workspaceId || null, + context, + originalName, + contentType, + size, + deletedAt: null, + uploadedAt: new Date(), + } + } + + const existing = await db + .select() + .from(workspaceFiles) + .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) + .limit(1) + if (existing.length > 0) { return { id: existing[0].id, @@ -60,6 +96,7 @@ export async function insertFileMetadata( originalName: existing[0].originalName, contentType: existing[0].contentType, size: existing[0].size, + deletedAt: existing[0].deletedAt, uploadedAt: existing[0].uploadedAt, } } @@ -76,6 +113,7 @@ export async function insertFileMetadata( originalName, contentType, size, + deletedAt: null, uploadedAt: new Date(), }) @@ -88,6 +126,7 @@ export async function insertFileMetadata( originalName, contentType, size, + deletedAt: null, uploadedAt: new Date(), } } catch (error) { @@ -98,7 +137,7 @@ export async function insertFileMetadata( const existingAfterError = await db .select() .from(workspaceFiles) - .where(eq(workspaceFiles.key, key)) + .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) .limit(1) if (existingAfterError.length > 0) { @@ -111,6 +150,7 @@ export async function insertFileMetadata( originalName: existingAfterError[0].originalName, contentType: existingAfterError[0].contentType, size: existingAfterError[0].size, + deletedAt: existingAfterError[0].deletedAt, uploadedAt: existingAfterError[0].uploadedAt, } } @@ -126,14 +166,20 @@ export async function insertFileMetadata( */ export async function getFileMetadataByKey( key: string, - context?: StorageContext + context?: StorageContext, + options?: { includeDeleted?: boolean } ): Promise { + const { includeDeleted = false } = options ?? {} const conditions = [eq(workspaceFiles.key, key)] if (context) { conditions.push(eq(workspaceFiles.context, context)) } + if (!includeDeleted) { + conditions.push(isNull(workspaceFiles.deletedAt)) + } + const [record] = await db .select() .from(workspaceFiles) @@ -153,6 +199,7 @@ export async function getFileMetadataByKey( originalName: record.originalName, contentType: record.contentType, size: record.size, + deletedAt: record.deletedAt, uploadedAt: record.uploadedAt, } } @@ -162,7 +209,7 @@ export async function getFileMetadataByKey( */ export async function getFileMetadataByContext( context: StorageContext, - options?: FileMetadataQueryOptions + options?: FileMetadataQueryOptions & { includeDeleted?: boolean } ): Promise { const conditions = [eq(workspaceFiles.context, context)] @@ -174,6 +221,10 @@ export async function getFileMetadataByContext( conditions.push(eq(workspaceFiles.userId, options.userId)) } + if (!options?.includeDeleted) { + conditions.push(isNull(workspaceFiles.deletedAt)) + } + const records = await db .select() .from(workspaceFiles) @@ -189,6 +240,7 @@ export async function getFileMetadataByContext( originalName: record.originalName, contentType: record.contentType, size: record.size, + deletedAt: record.deletedAt, uploadedAt: record.uploadedAt, })) } @@ -197,6 +249,9 @@ export async function getFileMetadataByContext( * Delete file metadata by key */ export async function deleteFileMetadata(key: string): Promise { - await db.delete(workspaceFiles).where(eq(workspaceFiles.key, key)) + await db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(and(eq(workspaceFiles.key, key), isNull(workspaceFiles.deletedAt))) return true } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index a4fce31dfb4..9eca0242137 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { nanoid } from 'nanoid' import type { NextRequest } from 'next/server' import { getProviderIdFromServiceId } from '@/lib/oauth' @@ -368,7 +368,7 @@ export async function saveTriggerWebhooksForDeploy({ const allWorkflowWebhooks = await db .select() .from(webhook) - .where(eq(webhook.workflowId, workflowId)) + .where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt))) // Separate webhooks by version: current deployment vs others const existingWebhooks: typeof allWorkflowWebhooks = [] @@ -765,9 +765,10 @@ export async function cleanupWebhooksForWorkflow( deploymentVersionId ? and( eq(webhook.workflowId, workflowId), - eq(webhook.deploymentVersionId, deploymentVersionId) + eq(webhook.deploymentVersionId, deploymentVersionId), + isNull(webhook.archivedAt) ) - : eq(webhook.workflowId, workflowId) + : and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt)) ) if (existingWebhooks.length === 0) { @@ -829,7 +830,7 @@ export async function restorePreviousVersionWebhooks(params: { const previousWebhooks = await db .select() .from(webhook) - .where(eq(webhook.deploymentVersionId, previousVersionId)) + .where(and(eq(webhook.deploymentVersionId, previousVersionId), isNull(webhook.archivedAt))) if (previousWebhooks.length === 0) { return @@ -841,7 +842,7 @@ export async function restorePreviousVersionWebhooks(params: { for (const wh of previousWebhooks) { try { - await createExternalWebhookSubscription( + const result = await createExternalWebhookSubscription( request, { id: wh.id, @@ -853,6 +854,13 @@ export async function restorePreviousVersionWebhooks(params: { userId, requestId ) + await db + .update(webhook) + .set({ + providerConfig: result.updatedProviderConfig, + updatedAt: new Date(), + }) + .where(eq(webhook.id, wh.id)) logger.info(`[${requestId}] Restored external subscription for webhook ${wh.id}`) } catch (restoreError) { logger.error( diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index c3af3843518..de3f0b98bab 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -316,6 +316,8 @@ export async function findWebhookAndWorkflow( and( eq(webhook.id, options.webhookId), eq(webhook.isActive, true), + isNull(webhook.archivedAt), + isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) @@ -351,6 +353,8 @@ export async function findWebhookAndWorkflow( and( eq(webhook.path, options.path), eq(webhook.isActive, true), + isNull(webhook.archivedAt), + isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) @@ -399,6 +403,8 @@ export async function findAllWebhooksForPath( and( eq(webhook.path, options.path), eq(webhook.isActive, true), + isNull(webhook.archivedAt), + isNull(workflow.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 8c8e15381ec..cb584ebc2ef 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,6 +1,6 @@ import crypto from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' -import { account, webhook } from '@sim/db/schema' +import { account, webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { nanoid } from 'nanoid' @@ -14,6 +14,7 @@ import { import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { getCredentialsForCredentialSet, refreshAccessTokenIfNeeded, @@ -2249,9 +2250,14 @@ export async function syncWebhooksForCredentialSet(params: { ? and( eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId), - eq(webhook.deploymentVersionId, deploymentVersionId) + eq(webhook.deploymentVersionId, deploymentVersionId), + isNull(webhook.archivedAt) + ) + : and( + eq(webhook.workflowId, workflowId), + eq(webhook.blockId, blockId), + isNull(webhook.archivedAt) ) - : and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)) ) // Filter to only webhooks belonging to this credential set @@ -2273,6 +2279,15 @@ export async function syncWebhooksForCredentialSet(params: { } const credentialIdsInSet = new Set(credentials.map((c) => c.credentialId)) + const [workflowRecord] = await db + .select({ + id: workflow.id, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) const result: CredentialSetWebhookSyncResult = { webhooks: [], @@ -2380,6 +2395,9 @@ export async function syncWebhooksForCredentialSet(params: { for (const [credentialId, existingWebhook] of existingByCredentialId) { if (!credentialIdsInSet.has(credentialId)) { try { + if (workflowRecord) { + await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId) + } await dbCtx.delete(webhook).where(eq(webhook.id, existingWebhook.id)) result.deleted++ @@ -2435,6 +2453,7 @@ export async function syncAllWebhooksForCredentialSet( .where( and( eq(webhook.credentialSetId, credentialSetId), + isNull(webhook.archivedAt), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) diff --git a/apps/sim/lib/workflows/active-context.ts b/apps/sim/lib/workflows/active-context.ts new file mode 100644 index 00000000000..612a00e4d37 --- /dev/null +++ b/apps/sim/lib/workflows/active-context.ts @@ -0,0 +1,58 @@ +import { db } from '@sim/db' +import { workflow, workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' + +export type ActiveWorkflowRecord = typeof workflow.$inferSelect + +export interface ActiveWorkflowContext { + workflow: ActiveWorkflowRecord + workspaceId: string +} + +/** + * Returns the workflow and workspace context only when both are still active. + */ +export async function getActiveWorkflowContext( + workflowId: string +): Promise { + const rows = await db + .select({ + workflow, + workspaceId: workspace.id, + }) + .from(workflow) + .innerJoin(workspace, eq(workflow.workspaceId, workspace.id)) + .where( + and(eq(workflow.id, workflowId), isNull(workflow.archivedAt), isNull(workspace.archivedAt)) + ) + .limit(1) + + if (rows.length === 0) { + return null + } + + return { + workflow: rows[0].workflow, + workspaceId: rows[0].workspaceId, + } +} + +/** + * Returns the workflow row only when its parent workspace is also active. + */ +export async function getActiveWorkflowRecord( + workflowId: string +): Promise { + const context = await getActiveWorkflowContext(workflowId) + return context?.workflow ?? null +} + +export async function assertActiveWorkflowContext( + workflowId: string +): Promise { + const context = await getActiveWorkflowContext(workflowId) + if (!context) { + throw new Error(`Active workflow not found: ${workflowId}`) + } + return context +} diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts index 490895a891f..1d4b72ecef0 100644 --- a/apps/sim/lib/workflows/executor/execution-state.ts +++ b/apps/sim/lib/workflows/executor/execution-state.ts @@ -34,6 +34,24 @@ export async function getExecutionState( return extractExecutionState(row?.executionData) } +export async function getExecutionStateForWorkflow( + executionId: string, + workflowId: string +): Promise { + const [row] = await db + .select({ executionData: workflowExecutionLogs.executionData }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, executionId), + eq(workflowExecutionLogs.workflowId, workflowId) + ) + ) + .limit(1) + + return extractExecutionState(row?.executionData) +} + export async function getLatestExecutionState( workflowId: string ): Promise { diff --git a/apps/sim/lib/workflows/lifecycle.test.ts b/apps/sim/lib/workflows/lifecycle.test.ts new file mode 100644 index 00000000000..473ff68a3c3 --- /dev/null +++ b/apps/sim/lib/workflows/lifecycle.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockSelect, + mockTransaction, + mockGetWorkflowById, + mockCleanupExternalWebhook, + mockWorkflowDeleted, +} = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockTransaction: vi.fn(), + mockGetWorkflowById: vi.fn(), + mockCleanupExternalWebhook: vi.fn(), + mockWorkflowDeleted: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + transaction: mockTransaction, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + a2aAgent: { archivedAt: 'a2a_archived_at' }, + chat: { archivedAt: 'chat_archived_at' }, + form: { archivedAt: 'form_archived_at' }, + webhook: { archivedAt: 'webhook_archived_at' }, + workflow: { archivedAt: 'workflow_archived_at' }, + workflowDeploymentVersion: { isActive: 'workflow_deployment_version_is_active' }, + workflowMcpTool: { archivedAt: 'workflow_mcp_tool_archived_at' }, + workflowSchedule: { archivedAt: 'workflow_schedule_archived_at' }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + getWorkflowById: (...args: unknown[]) => mockGetWorkflowById(...args), +})) + +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ + cleanupExternalWebhook: (...args: unknown[]) => mockCleanupExternalWebhook(...args), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { + SOCKET_SERVER_URL: 'http://socket.test', + INTERNAL_API_SECRET: 'secret', + }, +})) + +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { + workflowDeleted: (...args: unknown[]) => mockWorkflowDeleted(...args), + }, +})) + +import { archiveWorkflow } from '@/lib/workflows/lifecycle' + +function createSelectChain(result: T) { + const chain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(result), + } + + return chain +} + +function createUpdateChain() { + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + } +} + +describe('workflow lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + }) + + it('archives workflow and disables live surfaces', async () => { + mockGetWorkflowById + .mockResolvedValueOnce({ + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Workflow 1', + archivedAt: null, + }) + .mockResolvedValueOnce({ + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Workflow 1', + archivedAt: new Date(), + }) + + mockSelect.mockReturnValue(createSelectChain([])) + + const tx = { + update: vi.fn().mockImplementation(() => createUpdateChain()), + } + mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => + callback(tx) + ) + + const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) + + expect(result.archived).toBe(true) + expect(tx.update).toHaveBeenCalledTimes(8) + expect(mockWorkflowDeleted).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + expect(fetch).toHaveBeenCalledWith( + 'http://socket.test/api/workflow-deleted', + expect.any(Object) + ) + }) + + it('is idempotent for already archived workflows', async () => { + mockGetWorkflowById.mockResolvedValue({ + id: 'workflow-1', + userId: 'user-1', + workspaceId: 'workspace-1', + name: 'Workflow 1', + archivedAt: new Date(), + }) + + const result = await archiveWorkflow('workflow-1', { requestId: 'req-1' }) + + expect(result.archived).toBe(false) + expect(mockTransaction).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts new file mode 100644 index 00000000000..4de83dac6eb --- /dev/null +++ b/apps/sim/lib/workflows/lifecycle.ts @@ -0,0 +1,290 @@ +import { db } from '@sim/db' +import { + a2aAgent, + chat, + form, + webhook, + workflow, + workflowDeploymentVersion, + workflowMcpTool, + workflowSchedule, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { env } from '@/lib/core/config/env' +import { getRedisClient } from '@/lib/core/config/redis' +import { PlatformEvents } from '@/lib/core/telemetry' +import { mcpPubSub } from '@/lib/mcp/pubsub' +import { getWorkflowById } from '@/lib/workflows/utils' + +const logger = createLogger('WorkflowLifecycle') + +interface ArchiveWorkflowOptions { + requestId: string + notifySocket?: boolean +} + +async function notifyWorkflowArchived(workflowId: string, requestId: string): Promise { + try { + const socketUrl = env.SOCKET_SERVER_URL || 'http://localhost:3002' + const socketResponse = await fetch(`${socketUrl}/api/workflow-deleted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': env.INTERNAL_API_SECRET, + }, + body: JSON.stringify({ workflowId }), + }) + + if (!socketResponse.ok) { + logger.warn(`[${requestId}] Failed to notify Socket.IO about archived workflow ${workflowId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error notifying Socket.IO about archived workflow ${workflowId}`, { + error, + }) + } +} + +async function cleanupExternalWebhooksForWorkflow( + workflowId: string, + requestId: string +): Promise { + try { + const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions') + const webhooksToCleanup = 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.workflowId, workflowId)) + + for (const webhookData of webhooksToCleanup) { + try { + await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId) + } catch (error) { + logger.warn( + `[${requestId}] Failed to cleanup external webhook ${webhookData.webhook.id} for workflow ${workflowId}`, + { error } + ) + } + } + } catch (error) { + logger.warn(`[${requestId}] Error during external webhook cleanup for workflow ${workflowId}`, { + error, + }) + } +} + +async function clearA2AAgentCardCache(workflowId: string, requestId: string): Promise { + const redis = getRedisClient() + if (!redis) { + return + } + + try { + const agents = await db + .select({ id: a2aAgent.id }) + .from(a2aAgent) + .where(and(eq(a2aAgent.workflowId, workflowId), isNull(a2aAgent.archivedAt))) + + if (agents.length === 0) { + return + } + + await redis.del(...agents.map((agent) => `a2a:agent:${agent.id}:card`)) + } catch (error) { + logger.warn(`[${requestId}] Failed to clear A2A agent card cache for workflow ${workflowId}`, { + error, + }) + } +} + +export async function archiveWorkflow( + workflowId: string, + options: ArchiveWorkflowOptions +): Promise<{ archived: boolean; workflow: Awaited> | null }> { + const existingWorkflow = await getWorkflowById(workflowId, { includeArchived: true }) + + if (!existingWorkflow) { + return { archived: false, workflow: null } + } + + if (existingWorkflow.archivedAt) { + return { archived: false, workflow: existingWorkflow } + } + + const now = new Date() + const affectedWorkflowMcpServers = await db + .select({ serverId: workflowMcpTool.serverId }) + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + + await clearA2AAgentCardCache(workflowId, options.requestId) + + await db.transaction(async (tx) => { + await tx + .update(workflowSchedule) + .set({ + archivedAt: now, + updatedAt: now, + status: 'disabled', + nextRunAt: null, + lastQueuedAt: null, + }) + .where(and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt))) + + await tx + .update(webhook) + .set({ + archivedAt: now, + updatedAt: now, + isActive: false, + }) + .where(and(eq(webhook.workflowId, workflowId), isNull(webhook.archivedAt))) + + await tx + .update(chat) + .set({ + archivedAt: now, + updatedAt: now, + isActive: false, + }) + .where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt))) + + await tx + .update(form) + .set({ + archivedAt: now, + updatedAt: now, + isActive: false, + }) + .where(and(eq(form.workflowId, workflowId), isNull(form.archivedAt))) + + await tx + .update(workflowMcpTool) + .set({ + archivedAt: now, + updatedAt: now, + }) + .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + + await tx + .update(workflowDeploymentVersion) + .set({ + isActive: false, + }) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + await tx + .update(a2aAgent) + .set({ + archivedAt: now, + updatedAt: now, + isPublished: false, + }) + .where(and(eq(a2aAgent.workflowId, workflowId), isNull(a2aAgent.archivedAt))) + + await tx + .update(workflow) + .set({ + archivedAt: now, + updatedAt: now, + isDeployed: false, + isPublicApi: false, + }) + .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) + }) + + try { + PlatformEvents.workflowDeleted({ + workflowId, + workspaceId: existingWorkflow.workspaceId || undefined, + }) + } catch {} + + if (options.notifySocket !== false) { + await notifyWorkflowArchived(workflowId, options.requestId) + } + + await cleanupExternalWebhooksForWorkflow(workflowId, options.requestId) + + if (existingWorkflow.workspaceId && mcpPubSub && affectedWorkflowMcpServers.length > 0) { + const uniqueServerIds = [...new Set(affectedWorkflowMcpServers.map((row) => row.serverId))] + for (const serverId of uniqueServerIds) { + mcpPubSub.publishWorkflowToolsChanged({ + serverId, + workspaceId: existingWorkflow.workspaceId, + }) + } + } + + return { + archived: true, + workflow: await getWorkflowById(workflowId, { includeArchived: true }), + } +} + +export async function archiveWorkflows( + workflowIds: string[], + options: ArchiveWorkflowOptions +): Promise { + const uniqueWorkflowIds = Array.from(new Set(workflowIds)) + let archivedCount = 0 + + for (const workflowId of uniqueWorkflowIds) { + const result = await archiveWorkflow(workflowId, options) + if (result.archived) { + archivedCount += 1 + } + } + + return archivedCount +} + +export async function archiveWorkflowsForWorkspace( + workspaceId: string, + options: ArchiveWorkflowOptions +): Promise { + const workflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))) + + return archiveWorkflows( + workflows.map((entry) => entry.id), + options + ) +} + +export async function archiveWorkflowsByIdsInWorkspace( + workspaceId: string, + workflowIds: string[], + options: ArchiveWorkflowOptions +): Promise { + if (workflowIds.length === 0) { + return 0 + } + + const workflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where( + and( + eq(workflow.workspaceId, workspaceId), + isNull(workflow.archivedAt), + inArray(workflow.id, workflowIds) + ) + ) + + return archiveWorkflows( + workflows.map((entry) => entry.id), + options + ) +} diff --git a/apps/sim/lib/workflows/persistence/duplicate.test.ts b/apps/sim/lib/workflows/persistence/duplicate.test.ts index 1876f26a89f..95ada5cd481 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.test.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.test.ts @@ -20,6 +20,7 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: (...args: unknown[]) => mockAuthorizeWorkflowByWorkspacePermission(...args), + deduplicateWorkflowName: vi.fn(async (name: string) => name), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 10d49b65d64..0a0bc56e9cb 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -8,7 +8,10 @@ import { } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, min } from 'drizzle-orm' -import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { + authorizeWorkflowByWorkspacePermission, + deduplicateWorkflowName, +} from '@/lib/workflows/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import type { Variable } from '@/stores/panel/variables/types' import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types' @@ -168,14 +171,15 @@ export async function duplicateWorkflow( // Mapping from old variable IDs to new variable IDs (populated during variable duplication) const varIdMapping = new Map() - // Create the new workflow first (required for foreign key constraints) + const deduplicatedName = await deduplicateWorkflowName(name, targetWorkspaceId, targetFolderId) + await tx.insert(workflow).values({ id: newWorkflowId, userId, workspaceId: targetWorkspaceId, folderId: targetFolderId, sortOrder, - name, + name: deduplicatedName, description: description || source.description, color: color || source.color, lastSynced: now, @@ -380,7 +384,7 @@ export async function duplicateWorkflow( return { id: newWorkflowId, - name, + name: deduplicatedName, description: description || source.description, color: color || source.color, workspaceId: finalWorkspaceId, diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 89b7b7f6029..62cda965446 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' import type { DbOrTx } from '@/lib/db/types' +import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { backfillCanonicalModes, migrateSubblockIds, @@ -109,12 +110,8 @@ export async function loadDeployedWorkflowState( let resolvedWorkspaceId = providedWorkspaceId if (!resolvedWorkspaceId) { - const [wfRow] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - resolvedWorkspaceId = wfRow?.workspaceId ?? undefined + const workflowContext = await getActiveWorkflowContext(workflowId) + resolvedWorkspaceId = workflowContext?.workspaceId } if (!resolvedWorkspaceId) { @@ -1040,6 +1037,76 @@ export async function activateWorkflowVersion(params: { } } +export async function activateWorkflowVersionById(params: { + workflowId: string + deploymentVersionId: string +}): Promise<{ + success: boolean + deployedAt?: Date + state?: unknown + error?: string +}> { + const { workflowId, deploymentVersionId } = params + + try { + const [versionData] = await db + .select({ id: workflowDeploymentVersion.id, state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.id, deploymentVersionId) + ) + ) + .limit(1) + + if (!versionData) { + return { success: false, error: 'Deployment version not found' } + } + + const now = new Date() + + await db.transaction(async (tx) => { + await tx + .update(workflowDeploymentVersion) + .set({ isActive: false }) + .where(eq(workflowDeploymentVersion.workflowId, workflowId)) + + await tx + .update(workflowDeploymentVersion) + .set({ isActive: true }) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.id, deploymentVersionId) + ) + ) + + await tx + .update(workflow) + .set({ isDeployed: true, deployedAt: now }) + .where(eq(workflow.id, workflowId)) + }) + + logger.info(`Activated deployment version ${deploymentVersionId} for workflow ${workflowId}`) + + return { + success: true, + deployedAt: now, + state: versionData.state, + } + } catch (error) { + logger.error( + `Error activating deployment version ${deploymentVersionId} for workflow ${workflowId}:`, + error + ) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to activate version', + } + } +} + /** * List all deployment versions for a workflow. */ diff --git a/apps/sim/lib/workflows/schedules/deploy.test.ts b/apps/sim/lib/workflows/schedules/deploy.test.ts index b5039c0314e..0f48c1ef2aa 100644 --- a/apps/sim/lib/workflows/schedules/deploy.test.ts +++ b/apps/sim/lib/workflows/schedules/deploy.test.ts @@ -45,6 +45,7 @@ vi.mock('@sim/db', () => ({ blockId: 'block_id', deploymentVersionId: 'deployment_version_id', id: 'id', + archivedAt: 'archived_at', }, })) @@ -52,6 +53,7 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((...args) => ({ type: 'eq', args })), and: vi.fn((...args) => ({ type: 'and', args })), inArray: vi.fn((...args) => ({ type: 'inArray', args })), + isNull: vi.fn((...args) => ({ type: 'isNull', args })), sql: vi.fn((strings, ...values) => ({ type: 'sql', strings, values })), })) diff --git a/apps/sim/lib/workflows/schedules/deploy.ts b/apps/sim/lib/workflows/schedules/deploy.ts index 9344b018e47..7046e6ae581 100644 --- a/apps/sim/lib/workflows/schedules/deploy.ts +++ b/apps/sim/lib/workflows/schedules/deploy.ts @@ -1,6 +1,6 @@ import { db, workflowSchedule } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy' import type { BlockState } from '@/lib/workflows/schedules/utils' @@ -81,9 +81,10 @@ export async function createSchedulesForDeploy( deploymentVersionId ? and( eq(workflowSchedule.workflowId, workflowId), - eq(workflowSchedule.deploymentVersionId, deploymentVersionId) + eq(workflowSchedule.deploymentVersionId, deploymentVersionId), + isNull(workflowSchedule.archivedAt) ) - : eq(workflowSchedule.workflowId, workflowId) + : and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt)) ) const orphanedScheduleIds = existingSchedules @@ -178,9 +179,10 @@ export async function deleteSchedulesForWorkflow( deploymentVersionId ? and( eq(workflowSchedule.workflowId, workflowId), - eq(workflowSchedule.deploymentVersionId, deploymentVersionId) + eq(workflowSchedule.deploymentVersionId, deploymentVersionId), + isNull(workflowSchedule.archivedAt) ) - : eq(workflowSchedule.workflowId, workflowId) + : and(eq(workflowSchedule.workflowId, workflowId), isNull(workflowSchedule.archivedAt)) ) logger.info( diff --git a/apps/sim/lib/workflows/schedules/utils.test.ts b/apps/sim/lib/workflows/schedules/utils.test.ts index 409816b8782..338a5f9132d 100644 --- a/apps/sim/lib/workflows/schedules/utils.test.ts +++ b/apps/sim/lib/workflows/schedules/utils.test.ts @@ -504,11 +504,11 @@ describe('Schedule Utilities', () => { // cronstrue produces "At 30 minutes past the hour" for '30 * * * *' expect(parseCronToHumanReadable('30 * * * *')).toContain('30 minutes past the hour') - // cronstrue produces "At 09:00 AM" for '0 9 * * *' - expect(parseCronToHumanReadable('0 9 * * *')).toContain('09:00 AM') + // cronstrue produces "At 9:00 AM" for '0 9 * * *' (no leading zero on hour) + expect(parseCronToHumanReadable('0 9 * * *')).toContain('9:00 AM') - // cronstrue produces "At 02:30 PM" for '30 14 * * *' - expect(parseCronToHumanReadable('30 14 * * *')).toContain('02:30 PM') + // cronstrue produces "At 2:30 PM" for '30 14 * * *' (no leading zero on hour) + expect(parseCronToHumanReadable('30 14 * * *')).toContain('2:30 PM') // cronstrue produces "At 09:00 AM, only on Monday" for '0 9 * * 1' expect(parseCronToHumanReadable('0 9 * * 1')).toContain('Monday') @@ -521,12 +521,12 @@ describe('Schedule Utilities', () => { const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles') // Intl.DateTimeFormat returns PST or PDT depending on DST expect(resultPT).toMatch(/\(P[SD]T\)/) - expect(resultPT).toContain('09:00 AM') + expect(resultPT).toContain('9:00 AM') const resultET = parseCronToHumanReadable('30 14 * * *', 'America/New_York') // Intl.DateTimeFormat returns EST or EDT depending on DST expect(resultET).toMatch(/\(E[SD]T\)/) - expect(resultET).toContain('02:30 PM') + expect(resultET).toContain('2:30 PM') const resultUTC = parseCronToHumanReadable('0 12 * * *', 'UTC') expect(resultUTC).not.toContain('(UTC)') // UTC should not be explicitly shown diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index 08126963b09..0be09c73815 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -16,14 +16,19 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetSession } = vi.hoisted(() => ({ +const { mockGetSession, mockGetActiveWorkflowContext } = vi.hoisted(() => ({ mockGetSession: vi.fn(), + mockGetActiveWorkflowContext: vi.fn(), })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) +vi.mock('@/lib/workflows/active-context', () => ({ + getActiveWorkflowContext: mockGetActiveWorkflowContext, +})) + import { validateWorkflowPermissions } from '@/lib/workflows/utils' const mockDb = databaseMock.db @@ -62,11 +67,7 @@ describe('validateWorkflowPermissions', () => { describe('workflow not found', () => { it('should return 404 when workflow does not exist', async () => { mockGetSession.mockResolvedValue(mockSession) - - const mockLimit = vi.fn().mockResolvedValue([]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockGetActiveWorkflowContext.mockResolvedValue(null) const result = await validateWorkflowPermissions('non-existent', 'req-1', 'read') @@ -78,8 +79,12 @@ describe('validateWorkflowPermissions', () => { describe('owner access', () => { it('should deny access to workflow owner without workspace permissions for read action', async () => { mockGetSession.mockResolvedValue({ user: { id: 'owner-1', email: 'owner-1@test.com' } }) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', + }) - const mockLimit = vi.fn().mockResolvedValue([mockWorkflow]) + const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -91,8 +96,12 @@ describe('validateWorkflowPermissions', () => { it('should deny access to workflow owner without workspace permissions for write action', async () => { mockGetSession.mockResolvedValue({ user: { id: 'owner-1', email: 'owner-1@test.com' } }) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', + }) - const mockLimit = vi.fn().mockResolvedValue([mockWorkflow]) + const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -104,8 +113,12 @@ describe('validateWorkflowPermissions', () => { it('should deny access to workflow owner without workspace permissions for admin action', async () => { mockGetSession.mockResolvedValue({ user: { id: 'owner-1', email: 'owner-1@test.com' } }) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', + }) - const mockLimit = vi.fn().mockResolvedValue([mockWorkflow]) + const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -122,12 +135,12 @@ describe('validateWorkflowPermissions', () => { }) it('should grant read access to user with read permission', async () => { - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'read' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'read' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -138,12 +151,12 @@ describe('validateWorkflowPermissions', () => { }) it('should deny write access to user with only read permission', async () => { - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'read' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'read' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -155,12 +168,12 @@ describe('validateWorkflowPermissions', () => { }) it('should grant write access to user with write permission', async () => { - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'write' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'write' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -171,12 +184,12 @@ describe('validateWorkflowPermissions', () => { }) it('should grant write access to user with admin permission', async () => { - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'admin' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'admin' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -187,12 +200,12 @@ describe('validateWorkflowPermissions', () => { }) it('should deny admin access to user with only write permission', async () => { - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'write' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'write' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -204,12 +217,12 @@ describe('validateWorkflowPermissions', () => { }) it('should grant admin access to user with admin permission', async () => { - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'admin' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'admin' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -223,13 +236,12 @@ describe('validateWorkflowPermissions', () => { describe('no workspace permission', () => { it('should deny access to user without any workspace permission', async () => { mockGetSession.mockResolvedValue(mockSession) - - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) @@ -249,11 +261,10 @@ describe('validateWorkflowPermissions', () => { }) mockGetSession.mockResolvedValue(mockSession) - - const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: workflowWithoutWorkspace, + workspaceId: '', + }) const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read') @@ -268,11 +279,10 @@ describe('validateWorkflowPermissions', () => { }) mockGetSession.mockResolvedValue(mockSession) - - const mockLimit = vi.fn().mockResolvedValue([workflowWithoutWorkspace]) - const mockWhere = vi.fn(() => ({ limit: mockLimit })) - const mockFrom = vi.fn(() => ({ where: mockWhere })) - vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: workflowWithoutWorkspace, + workspaceId: '', + }) const result = await validateWorkflowPermissions('wf-2', 'req-1', 'read') @@ -283,13 +293,12 @@ describe('validateWorkflowPermissions', () => { describe('default action', () => { it('should default to read action when not specified', async () => { mockGetSession.mockResolvedValue(mockSession) - - let callCount = 0 - const mockLimit = vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) return Promise.resolve([mockWorkflow]) - return Promise.resolve([{ permissionType: 'read' }]) + mockGetActiveWorkflowContext.mockResolvedValue({ + workflow: mockWorkflow, + workspaceId: 'ws-1', }) + + const mockLimit = vi.fn().mockResolvedValue([{ permissionType: 'read' }]) const mockWhere = vi.fn(() => ({ limit: mockLimit })) const mockFrom = vi.fn(() => ({ where: mockWhere })) vi.mocked(mockDb.select).mockReturnValue({ from: mockFrom } as any) diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index b1cd58d53a8..d5c50b47ee6 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -2,9 +2,10 @@ import crypto from 'crypto' import { db } from '@sim/db' import { permissions, userStats, workflowFolder, workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, eq, inArray, isNull, max, min } from 'drizzle-orm' +import { and, asc, eq, inArray, isNull, max, min, sql } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' @@ -14,24 +15,99 @@ import type { ExecutionResult } from '@/executor/types' const logger = createLogger('WorkflowUtils') -export async function getWorkflowById(id: string) { - const rows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1) +export type WorkflowScope = 'active' | 'archived' | 'all' + +export async function getWorkflowById(id: string, options?: { includeArchived?: boolean }) { + const { includeArchived = false } = options ?? {} + const rows = await db + .select() + .from(workflowTable) + .where( + includeArchived + ? eq(workflowTable.id, id) + : and(eq(workflowTable.id, id), isNull(workflowTable.archivedAt)) + ) + .limit(1) return rows[0] } -export async function listWorkflows(workspaceId: string) { +export async function listWorkflows(workspaceId: string, options?: { scope?: WorkflowScope }) { + const { scope = 'active' } = options ?? {} return db .select() .from(workflowTable) - .where(eq(workflowTable.workspaceId, workspaceId)) + .where( + scope === 'all' + ? eq(workflowTable.workspaceId, workspaceId) + : scope === 'archived' + ? and( + eq(workflowTable.workspaceId, workspaceId), + sql`${workflowTable.archivedAt} IS NOT NULL` + ) + : and(eq(workflowTable.workspaceId, workspaceId), isNull(workflowTable.archivedAt)) + ) .orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt)) } +/** + * Generates a unique workflow name within a workspace+folder scope. + * If the name already exists among active workflows, appends (2), (3), etc. + */ +export async function deduplicateWorkflowName( + name: string, + workspaceId: string, + folderId: string | null | undefined +): Promise { + const folderCondition = folderId + ? eq(workflowTable.folderId, folderId) + : isNull(workflowTable.folderId) + + const [existing] = await db + .select({ id: workflowTable.id }) + .from(workflowTable) + .where( + and( + eq(workflowTable.workspaceId, workspaceId), + folderCondition, + eq(workflowTable.name, name), + isNull(workflowTable.archivedAt) + ) + ) + .limit(1) + + if (!existing) { + return name + } + + for (let i = 2; i < 100; i++) { + const candidate = `${name} (${i})` + const [dup] = await db + .select({ id: workflowTable.id }) + .from(workflowTable) + .where( + and( + eq(workflowTable.workspaceId, workspaceId), + folderCondition, + eq(workflowTable.name, candidate), + isNull(workflowTable.archivedAt) + ) + ) + .limit(1) + + if (!dup) { + return candidate + } + } + + return `${name} (${crypto.randomUUID().slice(0, 6)})` +} + export async function resolveWorkflowIdForUser( userId: string, workflowId?: string, - workflowName?: string + workflowName?: string, + workspaceId?: string ): Promise<{ workflowId: string; workflowName?: string } | null> { if (workflowId) { const authorization = await authorizeWorkflowByWorkspacePermission({ @@ -52,14 +128,19 @@ export async function resolveWorkflowIdForUser( .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) const workspaceIdList = workspaceIds.map((row) => row.entityId) - if (workspaceIdList.length === 0) { + const allowedWorkspaceIds = workspaceId + ? workspaceIdList.filter((candidateWorkspaceId) => candidateWorkspaceId === workspaceId) + : workspaceIdList + if (allowedWorkspaceIds.length === 0) { return null } const workflows = await db .select() .from(workflowTable) - .where(inArray(workflowTable.workspaceId, workspaceIdList)) + .where( + and(inArray(workflowTable.workspaceId, allowedWorkspaceIds), isNull(workflowTable.archivedAt)) + ) .orderBy(asc(workflowTable.sortOrder), asc(workflowTable.createdAt), asc(workflowTable.id)) if (workflows.length === 0) { @@ -259,8 +340,8 @@ export async function authorizeWorkflowByWorkspacePermission(params: { }): Promise { const { workflowId, userId, action = 'read' } = params - const workflow = await getWorkflowById(workflowId) - if (!workflow) { + const activeContext = await getActiveWorkflowContext(workflowId) + if (!activeContext) { return { allowed: false, status: 404, @@ -270,6 +351,8 @@ export async function authorizeWorkflowByWorkspacePermission(params: { } } + const workflow = activeContext.workflow + if (!workflow.workspaceId) { return { allowed: false, @@ -364,7 +447,13 @@ export async function createWorkflowRecord(params: CreateWorkflowInput) { db .select({ minOrder: min(workflowTable.sortOrder) }) .from(workflowTable) - .where(and(eq(workflowTable.workspaceId, workspaceId), workflowParentCondition)), + .where( + and( + eq(workflowTable.workspaceId, workspaceId), + workflowParentCondition, + isNull(workflowTable.archivedAt) + ) + ), db .select({ minOrder: min(workflowFolder.sortOrder) }) .from(workflowFolder) @@ -420,7 +509,11 @@ export async function updateWorkflowRecord( } export async function deleteWorkflowRecord(workflowId: string) { - await db.delete(workflowTable).where(eq(workflowTable.id, workflowId)) + const { archiveWorkflow } = await import('@/lib/workflows/lifecycle') + await archiveWorkflow(workflowId, { + requestId: `workflow-record-${workflowId}`, + notifySocket: false, + }) } export async function setWorkflowVariables(workflowId: string, variables: Record) { diff --git a/apps/sim/lib/workspaces/lifecycle.test.ts b/apps/sim/lib/workspaces/lifecycle.test.ts new file mode 100644 index 00000000000..cdc6c2fc611 --- /dev/null +++ b/apps/sim/lib/workspaces/lifecycle.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSelect, mockTransaction, mockArchiveWorkflowsForWorkspace, mockGetWorkspaceWithOwner } = + vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockTransaction: vi.fn(), + mockArchiveWorkflowsForWorkspace: vi.fn(), + mockGetWorkspaceWithOwner: vi.fn(), + })) + +vi.mock('@sim/db', () => ({ + db: { + select: mockSelect, + transaction: mockTransaction, + }, +})) + +vi.mock('@sim/db/schema', () => ({ + apiKey: { type: 'api_key_type' }, + document: { deletedAt: 'document_deleted_at', knowledgeBaseId: 'document_kb_id' }, + knowledgeBase: { deletedAt: 'kb_deleted_at' }, + knowledgeConnector: { deletedAt: 'knowledge_connector_deleted_at', knowledgeBaseId: 'kc_kb_id' }, + mcpServers: { deletedAt: 'mcp_servers_deleted_at' }, + userTableDefinitions: { archivedAt: 'table_archived_at' }, + workflowSchedule: { archivedAt: 'schedule_archived_at' }, + workspace: { archivedAt: 'workspace_archived_at' }, + workflowMcpServer: { isPublic: 'workflow_mcp_server_is_public' }, + workspaceFiles: { deletedAt: 'workspace_file_deleted_at' }, + workspaceInvitation: { status: 'workspace_invitation_status' }, + workspaceNotificationSubscription: { active: 'workspace_notification_active' }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})) + +vi.mock('@/lib/workflows/lifecycle', () => ({ + archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: (...args: unknown[]) => mockGetWorkspaceWithOwner(...args), +})) + +import { archiveWorkspace } from './lifecycle' + +function createUpdateChain() { + return { + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + } +} + +describe('workspace lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('archives workspace and dependent resources', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace 1', + ownerId: 'user-1', + archivedAt: null, + }) + mockArchiveWorkflowsForWorkspace.mockResolvedValue(2) + mockSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'server-1' }]), + }), + }) + + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]), + }), + }), + update: vi.fn().mockImplementation(() => createUpdateChain()), + delete: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockResolvedValue([]), + })), + } + mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise) => + callback(tx) + ) + + const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' }) + + expect(result).toEqual({ + archived: true, + workspaceName: 'Workspace 1', + }) + expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', { + requestId: 'req-1', + }) + expect(tx.update).toHaveBeenCalledTimes(11) + expect(tx.delete).toHaveBeenCalledTimes(1) + }) + + it('is idempotent for already archived workspaces', async () => { + mockGetWorkspaceWithOwner.mockResolvedValue({ + id: 'workspace-1', + name: 'Workspace 1', + ownerId: 'user-1', + archivedAt: new Date(), + }) + + const result = await archiveWorkspace('workspace-1', { requestId: 'req-1' }) + + expect(result).toEqual({ + archived: false, + workspaceName: 'Workspace 1', + }) + expect(mockArchiveWorkflowsForWorkspace).toHaveBeenCalledWith('workspace-1', { + requestId: 'req-1', + }) + expect(mockTransaction).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts new file mode 100644 index 00000000000..f5dfd42aa3d --- /dev/null +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -0,0 +1,197 @@ +import { db } from '@sim/db' +import { + apiKey, + document, + knowledgeBase, + knowledgeConnector, + mcpServers, + userTableDefinitions, + workflowMcpServer, + workflowSchedule, + workspace, + workspaceFiles, + workspaceInvitation, + workspaceNotificationSubscription, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray, isNull } from 'drizzle-orm' +import { mcpPubSub } from '@/lib/mcp/pubsub' +import { mcpService } from '@/lib/mcp/service' +import { archiveWorkflowsForWorkspace } from '@/lib/workflows/lifecycle' +import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceLifecycle') + +interface ArchiveWorkspaceOptions { + requestId: string +} + +export async function archiveWorkspace( + workspaceId: string, + options: ArchiveWorkspaceOptions +): Promise<{ archived: boolean; workspaceName?: string }> { + const workspaceRecord = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + + if (!workspaceRecord) { + return { archived: false } + } + + if (workspaceRecord.archivedAt) { + await archiveWorkflowsForWorkspace(workspaceId, options) + return { archived: false, workspaceName: workspaceRecord.name } + } + + const now = new Date() + const workflowMcpServerIds = await db + .select({ id: workflowMcpServer.id }) + .from(workflowMcpServer) + .where(eq(workflowMcpServer.workspaceId, workspaceId)) + + await db.transaction(async (tx) => { + await tx + .update(knowledgeBase) + .set({ + deletedAt: now, + updatedAt: now, + }) + .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))) + + const workspaceKbIds = await tx + .select({ id: knowledgeBase.id }) + .from(knowledgeBase) + .where(eq(knowledgeBase.workspaceId, workspaceId)) + + const knowledgeBaseIds = workspaceKbIds.map((entry) => entry.id) + if (knowledgeBaseIds.length > 0) { + await tx + .update(document) + .set({ archivedAt: now }) + .where( + and( + inArray(document.knowledgeBaseId, knowledgeBaseIds), + isNull(document.archivedAt), + isNull(document.deletedAt) + ) + ) + + await tx + .update(knowledgeConnector) + .set({ archivedAt: now, status: 'paused', updatedAt: now }) + .where( + and( + inArray(knowledgeConnector.knowledgeBaseId, knowledgeBaseIds), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + } + + await tx + .update(userTableDefinitions) + .set({ + archivedAt: now, + updatedAt: now, + }) + .where( + and( + eq(userTableDefinitions.workspaceId, workspaceId), + isNull(userTableDefinitions.archivedAt) + ) + ) + + await tx + .update(workspaceFiles) + .set({ + deletedAt: now, + }) + .where(and(eq(workspaceFiles.workspaceId, workspaceId), isNull(workspaceFiles.deletedAt))) + + await tx + .update(workspaceNotificationSubscription) + .set({ + active: false, + updatedAt: now, + }) + .where(eq(workspaceNotificationSubscription.workspaceId, workspaceId)) + + await tx + .update(workspaceInvitation) + .set({ + status: 'cancelled', + updatedAt: now, + }) + .where( + and( + eq(workspaceInvitation.workspaceId, workspaceId), + eq(workspaceInvitation.status, 'pending') + ) + ) + + await tx + .delete(apiKey) + .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) + + await tx + .update(workflowMcpServer) + .set({ + deletedAt: now, + isPublic: false, + updatedAt: now, + }) + .where(eq(workflowMcpServer.workspaceId, workspaceId)) + + await tx + .update(mcpServers) + .set({ + deletedAt: now, + enabled: false, + updatedAt: now, + }) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + + await tx + .update(workflowSchedule) + .set({ + archivedAt: now, + updatedAt: now, + status: 'disabled', + nextRunAt: null, + lastQueuedAt: null, + }) + .where( + and( + eq(workflowSchedule.sourceWorkspaceId, workspaceId), + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt) + ) + ) + + await tx + .update(workspace) + .set({ + archivedAt: now, + updatedAt: now, + }) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + }) + + await archiveWorkflowsForWorkspace(workspaceId, options) + + logger.info(`[${options.requestId}] Archived workspace ${workspaceId}`) + + await mcpService.clearCache(workspaceId).catch(() => undefined) + + if (mcpPubSub && workflowMcpServerIds.length > 0) { + for (const server of workflowMcpServerIds) { + mcpPubSub.publishWorkflowToolsChanged({ + serverId: server.id, + workspaceId, + }) + } + } + + return { + archived: true, + workspaceName: workspaceRecord.name, + } +} diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index a03419577a1..a78f899e382 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { permissions, type permissionTypeEnum, user, workspace } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] export interface WorkspaceBasic { @@ -11,6 +11,7 @@ export interface WorkspaceWithOwner { id: string name: string ownerId: string + archivedAt?: Date | null } export interface WorkspaceAccess { @@ -26,11 +27,19 @@ export interface WorkspaceAccess { * @param workspaceId - The workspace ID to check * @returns True if the workspace exists, false otherwise */ -export async function workspaceExists(workspaceId: string): Promise { +export async function workspaceExists( + workspaceId: string, + options?: { includeArchived?: boolean } +): Promise { + const { includeArchived = false } = options ?? {} const [ws] = await db .select({ id: workspace.id }) .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where( + includeArchived + ? eq(workspace.id, workspaceId) + : and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)) + ) .limit(1) return !!ws @@ -42,8 +51,11 @@ export async function workspaceExists(workspaceId: string): Promise { * @param workspaceId - The workspace ID to look up * @returns The workspace if found, null otherwise */ -export async function getWorkspaceById(workspaceId: string): Promise { - const exists = await workspaceExists(workspaceId) +export async function getWorkspaceById( + workspaceId: string, + options?: { includeArchived?: boolean } +): Promise { + const exists = await workspaceExists(workspaceId, options) return exists ? { id: workspaceId } : null } @@ -54,12 +66,23 @@ export async function getWorkspaceById(workspaceId: string): Promise { + const { includeArchived = false } = options ?? {} const [ws] = await db - .select({ id: workspace.id, name: workspace.name, ownerId: workspace.ownerId }) + .select({ + id: workspace.id, + name: workspace.name, + ownerId: workspace.ownerId, + archivedAt: workspace.archivedAt, + }) .from(workspace) - .where(eq(workspace.id, workspaceId)) + .where( + includeArchived + ? eq(workspace.id, workspaceId) + : and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)) + ) .limit(1) return ws || null @@ -111,6 +134,17 @@ export async function checkWorkspaceAccess( return { exists: true, hasAccess: true, canWrite, workspace: ws } } +export async function assertActiveWorkspaceAccess( + workspaceId: string, + userId: string +): Promise { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { + throw new Error(`Active workspace access denied: ${workspaceId}`) + } + return access +} + /** * Get the highest permission level a user has for a specific entity * @@ -124,6 +158,13 @@ export async function getUserEntityPermissions( entityType: string, entityId: string ): Promise { + if (entityType === 'workspace') { + const activeWorkspace = await workspaceExists(entityId) + if (!activeWorkspace) { + return null + } + } + const result = await db .select({ permissionType: permissions.permissionType }) .from(permissions) @@ -196,7 +237,14 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< }) .from(permissions) .innerJoin(user, eq(permissions.userId, user.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + isNull(workspace.archivedAt) + ) + ) .orderBy(user.email) return usersWithPermissions.map((row) => ({ @@ -229,7 +277,14 @@ export async function getWorkspaceMemberProfiles( }) .from(permissions) .innerJoin(user, eq(permissions.userId, user.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + .innerJoin(workspace, eq(permissions.entityId, workspace.id)) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + isNull(workspace.archivedAt) + ) + ) return rows } @@ -284,7 +339,7 @@ export async function getManageableWorkspaces(userId: string): Promise< ownerId: workspace.ownerId, }) .from(workspace) - .where(eq(workspace.ownerId, userId)) + .where(and(eq(workspace.ownerId, userId), isNull(workspace.archivedAt))) const adminWorkspaces = await db .select({ @@ -296,6 +351,7 @@ export async function getManageableWorkspaces(userId: string): Promise< .innerJoin(permissions, eq(permissions.entityId, workspace.id)) .where( and( + isNull(workspace.archivedAt), eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'), eq(permissions.permissionType, 'admin') diff --git a/apps/sim/lib/workspaces/utils.ts b/apps/sim/lib/workspaces/utils.ts index 22a788fd684..c485eb1e6c4 100644 --- a/apps/sim/lib/workspaces/utils.ts +++ b/apps/sim/lib/workspaces/utils.ts @@ -1,12 +1,14 @@ import { db } from '@sim/db' import { permissions, workspace as workspaceTable } from '@sim/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, isNull, sql } from 'drizzle-orm' interface WorkspaceBillingSettings { billedAccountUserId: string | null allowPersonalApiKeys: boolean } +export type WorkspaceScope = 'active' | 'archived' | 'all' + export async function getWorkspaceBillingSettings( workspaceId: string ): Promise { @@ -20,7 +22,7 @@ export async function getWorkspaceBillingSettings( allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys, }) .from(workspaceTable) - .where(eq(workspaceTable.id, workspaceId)) + .where(and(eq(workspaceTable.id, workspaceId), isNull(workspaceTable.archivedAt))) .limit(1) if (!rows.length) { @@ -38,7 +40,7 @@ export async function getWorkspaceBilledAccountUserId(workspaceId: string): Prom return settings?.billedAccountUserId ?? null } -export async function listUserWorkspaces(userId: string) { +export async function listUserWorkspaces(userId: string, scope: WorkspaceScope = 'active') { const workspaces = await db .select({ workspaceId: workspaceTable.id, @@ -48,7 +50,21 @@ export async function listUserWorkspaces(userId: string) { }) .from(permissions) .innerJoin(workspaceTable, eq(permissions.entityId, workspaceTable.id)) - .where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace'))) + .where( + scope === 'all' + ? and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')) + : scope === 'archived' + ? and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + sql`${workspaceTable.archivedAt} IS NOT NULL` + ) + : and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + isNull(workspaceTable.archivedAt) + ) + ) .orderBy(desc(workspaceTable.createdAt)) return workspaces.map((row) => ({ diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index a45accb6f6f..aad62ef8b3d 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -1,12 +1,13 @@ import * as schema from '@sim/db' import { webhook, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, inArray, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' +import { getActiveWorkflowContext } from '@/lib/workflows/active-context' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { mergeSubBlockValues } from '@/lib/workflows/subblocks' import { @@ -174,7 +175,7 @@ export async function getWorkflowState(workflowId: string) { const workflowData = await db .select() .from(workflow) - .where(eq(workflow.id, workflowId)) + .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) .limit(1) if (!workflowData.length) { @@ -217,6 +218,11 @@ export async function persistWorkflowOperation(workflowId: string, operation: an try { const { operation: op, target, payload, timestamp, userId } = operation + const activeWorkflow = await getActiveWorkflowContext(workflowId) + if (!activeWorkflow) { + throw new Error(`Workflow ${workflowId} is archived or unavailable`) + } + if (op === BLOCK_OPERATIONS.UPDATE_POSITION && Math.random() < 0.01) { logger.debug('Socket DB operation sample:', { operation: op, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 9b63b5ba7e8..244cf1b07b5 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { BLOCK_OPERATIONS, @@ -93,7 +93,7 @@ export async function verifyWorkflowAccess( name: workflow.name, }) .from(workflow) - .where(eq(workflow.id, workflowId)) + .where(and(eq(workflow.id, workflowId), isNull(workflow.archivedAt))) .limit(1) if (!workflowData.length) { diff --git a/packages/db/migrations/0172_silky_magma.sql b/packages/db/migrations/0172_silky_magma.sql new file mode 100644 index 00000000000..1a6710f2da3 --- /dev/null +++ b/packages/db/migrations/0172_silky_magma.sql @@ -0,0 +1,66 @@ +ALTER TABLE "workspace_files" DROP CONSTRAINT "workspace_files_key_unique";--> statement-breakpoint +ALTER TABLE "document" DROP CONSTRAINT "document_connector_id_knowledge_connector_id_fk"; +--> statement-breakpoint +DROP INDEX "a2a_agent_workspace_workflow_unique";--> statement-breakpoint +DROP INDEX "identifier_idx";--> statement-breakpoint +DROP INDEX "form_identifier_idx";--> statement-breakpoint +DROP INDEX "user_table_def_workspace_name_unique";--> statement-breakpoint +DROP INDEX "path_deployment_unique";--> statement-breakpoint +DROP INDEX "workflow_mcp_tool_server_workflow_unique";--> statement-breakpoint +DROP INDEX "workflow_schedule_workflow_block_deployment_unique";--> statement-breakpoint +ALTER TABLE "a2a_agent" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "chat" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "copilot_chats" ADD COLUMN "resources" jsonb DEFAULT '[]' NOT NULL;--> statement-breakpoint +ALTER TABLE "document" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "form" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "knowledge_connector" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "user_table_definitions" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "workflow" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "workflow_mcp_server" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "workflow_mcp_tool" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "workflow_schedule" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "workspace" ADD COLUMN "archived_at" timestamp;--> statement-breakpoint +ALTER TABLE "workspace_file" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "workspace_files" ADD COLUMN "deleted_at" timestamp;--> statement-breakpoint +ALTER TABLE "document" ADD CONSTRAINT "document_connector_id_knowledge_connector_id_fk" FOREIGN KEY ("connector_id") REFERENCES "public"."knowledge_connector"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "a2a_agent_archived_at_idx" ON "a2a_agent" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "chat_archived_at_idx" ON "chat" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "doc_archived_at_idx" ON "document" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "doc_deleted_at_idx" ON "document" USING btree ("deleted_at");--> statement-breakpoint +CREATE INDEX "form_archived_at_idx" ON "form" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "kc_archived_at_idx" ON "knowledge_connector" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "kc_deleted_at_idx" ON "knowledge_connector" USING btree ("deleted_at");--> statement-breakpoint +CREATE INDEX "user_table_def_archived_at_idx" ON "user_table_definitions" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "webhook_archived_at_idx" ON "webhook" USING btree ("archived_at");--> statement-breakpoint +-- Deduplicate workflow names within (workspace_id, folder_id) before adding the unique index. +-- Keeps the most recently created workflow with its original name; older duplicates get " (2)", " (3)", etc. +WITH duplicates AS ( + SELECT id, name, workspace_id, folder_id, + ROW_NUMBER() OVER ( + PARTITION BY workspace_id, COALESCE(folder_id, ''), name + ORDER BY created_at DESC + ) AS rn + FROM workflow + WHERE archived_at IS NULL +) +UPDATE workflow +SET name = workflow.name || ' (' || duplicates.rn || ')' +FROM duplicates +WHERE workflow.id = duplicates.id + AND duplicates.rn > 1;--> statement-breakpoint +CREATE UNIQUE INDEX "workflow_workspace_folder_name_active_unique" ON "workflow" USING btree ("workspace_id",coalesce("folder_id", ''),"name") WHERE "workflow"."archived_at" IS NULL;--> statement-breakpoint +CREATE INDEX "workflow_archived_at_idx" ON "workflow" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "workflow_mcp_server_deleted_at_idx" ON "workflow_mcp_server" USING btree ("deleted_at");--> statement-breakpoint +CREATE INDEX "workflow_mcp_tool_archived_at_idx" ON "workflow_mcp_tool" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "workflow_schedule_archived_at_idx" ON "workflow_schedule" USING btree ("archived_at");--> statement-breakpoint +CREATE INDEX "workspace_file_deleted_at_idx" ON "workspace_file" USING btree ("deleted_at");--> statement-breakpoint +CREATE UNIQUE INDEX "workspace_files_key_active_unique" ON "workspace_files" USING btree ("key") WHERE "workspace_files"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX "workspace_files_deleted_at_idx" ON "workspace_files" USING btree ("deleted_at");--> statement-breakpoint +CREATE UNIQUE INDEX "a2a_agent_workspace_workflow_unique" ON "a2a_agent" USING btree ("workspace_id","workflow_id") WHERE "a2a_agent"."archived_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "identifier_idx" ON "chat" USING btree ("identifier") WHERE "chat"."archived_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "form_identifier_idx" ON "form" USING btree ("identifier") WHERE "form"."archived_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "user_table_def_workspace_name_unique" ON "user_table_definitions" USING btree ("workspace_id","name") WHERE "user_table_definitions"."archived_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "path_deployment_unique" ON "webhook" USING btree ("path","deployment_version_id") WHERE "webhook"."archived_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "workflow_mcp_tool_server_workflow_unique" ON "workflow_mcp_tool" USING btree ("server_id","workflow_id") WHERE "workflow_mcp_tool"."archived_at" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "workflow_schedule_workflow_block_deployment_unique" ON "workflow_schedule" USING btree ("workflow_id","block_id","deployment_version_id") WHERE "workflow_schedule"."archived_at" IS NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0172_snapshot.json b/packages/db/migrations/meta/0172_snapshot.json new file mode 100644 index 00000000000..d5f484ac25b --- /dev/null +++ b/packages/db/migrations/meta/0172_snapshot.json @@ -0,0 +1,13412 @@ +{ + "id": "d2304a4b-0b0f-4cb9-b325-0fa29ee60d13", + "prevId": "ffa08eb3-e03d-4418-880d-8fdc17091fa5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"a2a_agent\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_archived_at_idx": { + "name": "a2a_agent_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_idx": { + "name": "chat_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_idx": { + "name": "doc_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_idx": { + "name": "doc_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form": { + "name": "form", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"form\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_archived_at_idx": { + "name": "form_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_idx": { + "name": "kc_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_idx": { + "name": "kc_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_access_token_idx": { + "name": "oauth_access_token_access_token_idx", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_token_idx": { + "name": "oauth_access_token_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_application_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_access_token_unique": { + "name": "oauth_access_token_access_token_unique", + "nullsNotDistinct": false, + "columns": ["access_token"] + }, + "oauth_access_token_refresh_token_unique": { + "name": "oauth_access_token_refresh_token_unique", + "nullsNotDistinct": false, + "columns": ["refresh_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_application": { + "name": "oauth_application", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_application_client_id_idx": { + "name": "oauth_application_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_application_user_id_user_id_fk": { + "name": "oauth_application_user_id_user_id_fk", + "tableFrom": "oauth_application", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_application_client_id_unique": { + "name": "oauth_application_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consent_given": { + "name": "consent_given", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_application_client_id_fk": { + "name": "oauth_consent_client_id_oauth_application_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_application", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_name_unique": { + "name": "permission_group_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_org_auto_add_unique": { + "name": "permission_group_org_auto_add_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_user_id_unique": { + "name": "permission_group_member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_attribution": { + "name": "referral_attribution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer_url": { + "name": "referrer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_amount": { + "name": "bonus_credit_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_attribution_user_id_idx": { + "name": "referral_attribution_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_org_unique_idx": { + "name": "referral_attribution_org_unique_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"referral_attribution\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_campaign_id_idx": { + "name": "referral_attribution_campaign_id_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_utm_campaign_idx": { + "name": "referral_attribution_utm_campaign_idx", + "columns": [ + { + "expression": "utm_campaign", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_utm_content_idx": { + "name": "referral_attribution_utm_content_idx", + "columns": [ + { + "expression": "utm_content", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_created_at_idx": { + "name": "referral_attribution_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_attribution_user_id_user_id_fk": { + "name": "referral_attribution_user_id_user_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_attribution_organization_id_organization_id_fk": { + "name": "referral_attribution_organization_id_organization_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "referral_attribution_campaign_id_referral_campaigns_id_fk": { + "name": "referral_attribution_campaign_id_referral_campaigns_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "referral_campaigns", + "columnsFrom": ["campaign_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_attribution_user_id_unique": { + "name": "referral_attribution_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_campaigns": { + "name": "referral_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_amount": { + "name": "bonus_credit_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_campaigns_active_idx": { + "name": "referral_campaigns_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_campaigns_code_unique": { + "name": "referral_campaigns_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_table_id_idx": { + "name": "user_table_rows_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_data_gin_idx": { + "name": "user_table_rows_data_gin_idx", + "columns": [ + { + "expression": "data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_idx": { + "name": "webhook_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_idx": { + "name": "workflow_mcp_tool_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_idx": { + "name": "workflow_schedule_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#32bd7e'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": ["workflow", "wand", "copilot", "workspace-chat", "mcp_copilot", "mothership_block"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 35c0c508e9e..4b2248767d1 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1198,6 +1198,13 @@ "when": 1773353601402, "tag": "0171_yielding_venom", "breakpoints": true + }, + { + "idx": 172, + "version": "7", + "when": 1773390821375, + "tag": "0172_silky_magma", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 2f762e64594..3839075f2c1 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -158,12 +158,17 @@ export const workflow = pgTable( runCount: integer('run_count').notNull().default(0), lastRunAt: timestamp('last_run_at'), variables: json('variables').default('{}'), + archivedAt: timestamp('archived_at'), }, (table) => ({ userIdIdx: index('workflow_user_id_idx').on(table.userId), workspaceIdIdx: index('workflow_workspace_id_idx').on(table.workspaceId), userWorkspaceIdx: index('workflow_user_workspace_idx').on(table.userId, table.workspaceId), + workspaceFolderNameUnique: uniqueIndex('workflow_workspace_folder_name_active_unique') + .on(table.workspaceId, sql`coalesce(${table.folderId}, '')`, table.name) + .where(sql`${table.archivedAt} IS NULL`), folderSortIdx: index('workflow_folder_sort_idx').on(table.folderId, table.sortOrder), + archivedAtIdx: index('workflow_archived_at_idx').on(table.archivedAt), }) ) @@ -515,20 +520,20 @@ export const workflowSchedule = pgTable( onDelete: 'cascade', }), jobHistory: jsonb('job_history').$type>(), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => { return { - workflowBlockUnique: uniqueIndex('workflow_schedule_workflow_block_deployment_unique').on( - table.workflowId, - table.blockId, - table.deploymentVersionId - ), + workflowBlockUnique: uniqueIndex('workflow_schedule_workflow_block_deployment_unique') + .on(table.workflowId, table.blockId, table.deploymentVersionId) + .where(sql`${table.archivedAt} IS NULL`), workflowDeploymentIdx: index('workflow_schedule_workflow_deployment_idx').on( table.workflowId, table.deploymentVersionId ), + archivedAtIdx: index('workflow_schedule_archived_at_idx').on(table.archivedAt), } } ) @@ -584,13 +589,16 @@ export const webhook = pgTable( credentialSetId: text('credential_set_id').references(() => credentialSet.id, { onDelete: 'set null', }), // For credential set webhooks - enables efficient queries + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => { return { // Ensure webhook paths are unique per deployment version - pathIdx: uniqueIndex('path_deployment_unique').on(table.path, table.deploymentVersionId), + pathIdx: uniqueIndex('path_deployment_unique') + .on(table.path, table.deploymentVersionId) + .where(sql`${table.archivedAt} IS NULL`), // Optimize queries for webhooks by workflow and block workflowBlockIdx: index('idx_webhook_on_workflow_id_block_id').on( table.workflowId, @@ -602,6 +610,7 @@ export const webhook = pgTable( ), // Optimize queries for credential set webhooks credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId), + archivedAtIdx: index('webhook_archived_at_idx').on(table.archivedAt), } } ) @@ -924,13 +933,17 @@ export const chat = pgTable( // Output configuration outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => { return { // Ensure identifiers are unique - identifierIdx: uniqueIndex('identifier_idx').on(table.identifier), + identifierIdx: uniqueIndex('identifier_idx') + .on(table.identifier) + .where(sql`${table.archivedAt} IS NULL`), + archivedAtIdx: index('chat_archived_at_idx').on(table.archivedAt), } } ) @@ -962,13 +975,17 @@ export const form = pgTable( // Branding showBranding: boolean('show_branding').notNull().default(true), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ - identifierIdx: uniqueIndex('form_identifier_idx').on(table.identifier), + identifierIdx: uniqueIndex('form_identifier_idx') + .on(table.identifier) + .where(sql`${table.archivedAt} IS NULL`), workflowIdIdx: index('form_workflow_id_idx').on(table.workflowId), userIdIdx: index('form_user_id_idx').on(table.userId), + archivedAtIdx: index('form_archived_at_idx').on(table.archivedAt), }) ) @@ -1038,6 +1055,7 @@ export const workspace = pgTable('workspace', { .notNull() .references(() => user.id, { onDelete: 'no action' }), allowPersonalApiKeys: boolean('allow_personal_api_keys').notNull().default(true), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }) @@ -1056,11 +1074,13 @@ export const workspaceFile = pgTable( uploadedBy: text('uploaded_by') .notNull() .references(() => user.id, { onDelete: 'cascade' }), + deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), }, (table) => ({ workspaceIdIdx: index('workspace_file_workspace_id_idx').on(table.workspaceId), keyIdx: index('workspace_file_key_idx').on(table.key), + deletedAtIdx: index('workspace_file_deleted_at_idx').on(table.deletedAt), }) ) @@ -1068,7 +1088,7 @@ export const workspaceFiles = pgTable( 'workspace_files', { id: text('id').primaryKey(), - key: text('key').notNull().unique(), + key: text('key').notNull(), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), @@ -1077,13 +1097,18 @@ export const workspaceFiles = pgTable( originalName: text('original_name').notNull(), contentType: text('content_type').notNull(), size: integer('size').notNull(), + deletedAt: timestamp('deleted_at'), uploadedAt: timestamp('uploaded_at').notNull().defaultNow(), }, (table) => ({ + keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique') + .on(table.key) + .where(sql`${table.deletedAt} IS NULL`), keyIdx: index('workspace_files_key_idx').on(table.key), userIdIdx: index('workspace_files_user_id_idx').on(table.userId), workspaceIdIdx: index('workspace_files_workspace_id_idx').on(table.workspaceId), contextIdx: index('workspace_files_context_idx').on(table.context), + deletedAtIdx: index('workspace_files_deleted_at_idx').on(table.deletedAt), }) ) @@ -1256,6 +1281,7 @@ export const document = pgTable( // Document state enabled: boolean('enabled').notNull().default(true), // Enable/disable from knowledge base + archivedAt: timestamp('archived_at'), // Parent KB/workspace archive marker deletedAt: timestamp('deleted_at'), // Soft delete userExcluded: boolean('user_excluded').notNull().default(false), // User explicitly excluded — skip on sync @@ -1284,7 +1310,7 @@ export const document = pgTable( // Connector-sourced document fields connectorId: text('connector_id').references(() => knowledgeConnector.id, { - onDelete: 'set null', + onDelete: 'cascade', }), externalId: text('external_id'), contentHash: text('content_hash'), @@ -1309,6 +1335,8 @@ export const document = pgTable( .where(sql`${table.deletedAt} IS NULL`), // Sync engine: load all active docs for a connector connectorIdIdx: index('doc_connector_id_idx').on(table.connectorId), + archivedAtIdx: index('doc_archived_at_idx').on(table.archivedAt), + deletedAtIdx: index('doc_deleted_at_idx').on(table.deletedAt), // Text tag indexes tag1Idx: index('doc_tag1_idx').on(table.tag1), tag2Idx: index('doc_tag2_idx').on(table.tag2), @@ -1907,12 +1935,14 @@ export const workflowMcpServer = pgTable( name: text('name').notNull(), description: text('description'), isPublic: boolean('is_public').notNull().default(false), + deletedAt: timestamp('deleted_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ workspaceIdIdx: index('workflow_mcp_server_workspace_id_idx').on(table.workspaceId), createdByIdx: index('workflow_mcp_server_created_by_idx').on(table.createdBy), + deletedAtIdx: index('workflow_mcp_server_deleted_at_idx').on(table.deletedAt), }) ) @@ -1933,16 +1963,17 @@ export const workflowMcpTool = pgTable( toolName: text('tool_name').notNull(), toolDescription: text('tool_description'), parameterSchema: json('parameter_schema').notNull().default('{}'), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ serverIdIdx: index('workflow_mcp_tool_server_id_idx').on(table.serverId), workflowIdIdx: index('workflow_mcp_tool_workflow_id_idx').on(table.workflowId), - serverWorkflowUnique: uniqueIndex('workflow_mcp_tool_server_workflow_unique').on( - table.serverId, - table.workflowId - ), + serverWorkflowUnique: uniqueIndex('workflow_mcp_tool_server_workflow_unique') + .on(table.serverId, table.workflowId) + .where(sql`${table.archivedAt} IS NULL`), + archivedAtIdx: index('workflow_mcp_tool_archived_at_idx').on(table.archivedAt), }) ) @@ -2000,16 +2031,17 @@ export const a2aAgent = pgTable( /** When the agent was published */ publishedAt: timestamp('published_at'), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ workflowIdIdx: index('a2a_agent_workflow_id_idx').on(table.workflowId), createdByIdx: index('a2a_agent_created_by_idx').on(table.createdBy), - workspaceWorkflowUnique: uniqueIndex('a2a_agent_workspace_workflow_unique').on( - table.workspaceId, - table.workflowId - ), + workspaceWorkflowUnique: uniqueIndex('a2a_agent_workspace_workflow_unique') + .on(table.workspaceId, table.workflowId) + .where(sql`${table.archivedAt} IS NULL`), + archivedAtIdx: index('a2a_agent_archived_at_idx').on(table.archivedAt), }) ) @@ -2473,11 +2505,14 @@ export const knowledgeConnector = pgTable( consecutiveFailures: integer('consecutive_failures').notNull().default(0), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), + archivedAt: timestamp('archived_at'), deletedAt: timestamp('deleted_at'), }, (table) => ({ knowledgeBaseIdIdx: index('kc_knowledge_base_id_idx').on(table.knowledgeBaseId), statusNextSyncIdx: index('kc_status_next_sync_idx').on(table.status, table.nextSyncAt), + archivedAtIdx: index('kc_archived_at_idx').on(table.archivedAt), + deletedAtIdx: index('kc_deleted_at_idx').on(table.deletedAt), }) ) @@ -2532,6 +2567,7 @@ export const userTableDefinitions = pgTable( metadata: jsonb('metadata'), maxRows: integer('max_rows').notNull().default(10000), rowCount: integer('row_count').notNull().default(0), + archivedAt: timestamp('archived_at'), createdBy: text('created_by') .notNull() .references(() => user.id, { onDelete: 'cascade' }), @@ -2540,10 +2576,10 @@ export const userTableDefinitions = pgTable( }, (table) => ({ workspaceIdIdx: index('user_table_def_workspace_id_idx').on(table.workspaceId), - workspaceNameUnique: uniqueIndex('user_table_def_workspace_name_unique').on( - table.workspaceId, - table.name - ), + workspaceNameUnique: uniqueIndex('user_table_def_workspace_name_unique') + .on(table.workspaceId, table.name) + .where(sql`${table.archivedAt} IS NULL`), + archivedAtIdx: index('user_table_def_archived_at_idx').on(table.archivedAt), }) )