Skip to content
Merged
10 changes: 10 additions & 0 deletions apps/sim/app/api/copilot/chat/delete/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,16 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ success: true })
}

// Mothership (sidebar) chats are soft-deleted through their own route so
// they land in Recently Deleted — this legacy hard-delete must not bypass
// that.
if (chat.type === 'mothership') {
return NextResponse.json(
{ success: false, error: 'Use the mothership chat delete endpoint' },
{ status: 400 }
)
}

const [deleted] = await db
.delete(copilotChats)
.where(and(eq(copilotChats.id, parsed.chatId), eq(copilotChats.userId, session.user.id)))
Expand Down
10 changes: 8 additions & 2 deletions apps/sim/app/api/copilot/chat/queries.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
Expand DownExpand Up@@ -189,7 +189,13 @@ export async function GET(req: NextRequest) {
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
.where(
and(
eq(copilotChats.userId, authenticatedUserId),
isNull(copilotChats.deletedAt),
scopeFilter
)
)
.orderBy(desc(copilotChats.updatedAt))

const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`
Expand Down
42 changes: 36 additions & 6 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { copilotChats } 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 {
addCopilotChatResourceContract,
Expand DownExpand Up@@ -64,7 +64,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const [chat] = await db
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
.limit(1)

if (!chat) {
Expand All@@ -91,7 +97,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
await db
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)

logger.info('Added resource to chat', { chatId, resource })

Expand DownExpand Up@@ -124,7 +136,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
const [chat] = await db
.select({ resources: copilotChats.resources })
.from(copilotChats)
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
.limit(1)

if (!chat) {
Expand All@@ -142,7 +160,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
await db
.update(copilotChats)
.set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() })
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)

logger.info('Reordered resources for chat', { chatId, count: newOrder.length })

Expand DownExpand Up@@ -182,7 +206,13 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
), '[]'::jsonb)`,
updatedAt: new Date(),
})
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt)
)
)
.returning({ resources: copilotChats.resources })

if (!updated) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,7 @@ vi.mock('@/lib/copilot/chat/messages-store', () => ({
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
}))

import { POST } from '@/app/api/copilot/chat/update-messages/route'
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/copilot/chats/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
.where(
and(
eq(copilotChats.userId, userId),
isNull(copilotChats.deletedAt),
or(
and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)),
inAccessibleWorkspace
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,15 +70,18 @@ vi.mock('@sim/db/schema', () => ({
previewYaml: 'copilotChats.previewYaml',
planArtifact: 'copilotChats.planArtifact',
config: 'copilotChats.config',
deletedAt: 'copilotChats.deletedAt',
},
workspaceFiles: {
id: 'workspaceFiles.id',
},
}))

vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
}))

vi.mock('@/lib/copilot/resources/persistence', () => ({
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { copilotChats, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq, inArray } from 'drizzle-orm'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
Expand DownExpand Up@@ -84,7 +84,7 @@ export const POST = withRouteHandler(
config: copilotChats.config,
})
.from(copilotChats)
.where(eq(copilotChats.id, chatId))
.where(and(eq(copilotChats.id, chatId), isNull(copilotChats.deletedAt)))
.limit(1)

if (!parent || parent.userId !== userId || parent.type !== 'mothership') {
Expand Down
163 changes: 163 additions & 0 deletions apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockDbSelect,
mockSelectFrom,
mockSelectWhere,
mockSelectLimit,
mockDbUpdate,
mockDbSet,
mockUpdateWhere,
mockDbReturning,
mockAssertActiveWorkspaceAccess,
mockPublishStatusChanged,
} = vi.hoisted(() => ({
mockDbSelect: vi.fn(),
mockSelectFrom: vi.fn(),
mockSelectWhere: vi.fn(),
mockSelectLimit: vi.fn(),
mockDbUpdate: vi.fn(),
mockDbSet: vi.fn(),
mockUpdateWhere: vi.fn(),
mockDbReturning: vi.fn(),
mockAssertActiveWorkspaceAccess: vi.fn(),
mockPublishStatusChanged: vi.fn(),
}))

vi.mock('@sim/db', () => ({
db: {
select: mockDbSelect,
update: mockDbUpdate,
},
}))

vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'copilotChats.id',
userId: 'copilotChats.userId',
type: 'copilotChats.type',
workspaceId: 'copilotChats.workspaceId',
updatedAt: 'copilotChats.updatedAt',
lastSeenAt: 'copilotChats.lastSeenAt',
deletedAt: 'copilotChats.deletedAt',
},
}))

vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })),
}))

vi.mock('@/lib/copilot/request/http', () => ({
...copilotHttpMock,
createForbiddenResponse: vi.fn((message: string) => ({
status: 403,
ok: false,
json: async () => ({ error: message }),
})),
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError: (error: unknown) =>
error instanceof Error && error.message === 'ACCESS_DENIED',
}))

vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: mockPublishStatusChanged },
}))

vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: vi.fn(),
}))

import { POST } from '@/app/api/mothership/chats/[chatId]/restore/route'

function makeRequest(chatId: string) {
return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/restore`, {
method: 'POST',
})
}

function makeContext(chatId: string) {
return { params: Promise.resolve({ chatId }) }
}

describe('POST /api/mothership/chats/[chatId]/restore', () => {
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
mockSelectLimit.mockResolvedValue([{ workspaceId: 'workspace-1' }])
mockSelectWhere.mockReturnValue({ limit: mockSelectLimit })
mockSelectFrom.mockReturnValue({ where: mockSelectWhere })
mockDbSelect.mockReturnValue({ from: mockSelectFrom })
mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }])
mockUpdateWhere.mockReturnValue({ returning: mockDbReturning })
mockDbSet.mockReturnValue({ where: mockUpdateWhere })
mockDbUpdate.mockReturnValue({ set: mockDbSet })
mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined)
})

it('returns 401 when unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})

const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
expect(response.status).toBe(401)
expect(mockDbUpdate).not.toHaveBeenCalled()
})

it('returns 404 when no soft-deleted chat is owned by the caller', async () => {
mockSelectLimit.mockResolvedValueOnce([])

const response = await POST(makeRequest('chat-missing'), makeContext('chat-missing'))
expect(response.status).toBe(404)
expect(mockAssertActiveWorkspaceAccess).not.toHaveBeenCalled()
expect(mockDbUpdate).not.toHaveBeenCalled()
})

it('returns 403 when the caller lost access to the workspace', async () => {
mockAssertActiveWorkspaceAccess.mockRejectedValueOnce(new Error('ACCESS_DENIED'))

const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
expect(response.status).toBe(403)
expect(mockDbUpdate).not.toHaveBeenCalled()
})

it('restores the chat, bumping updatedAt and lastSeenAt, and publishes the event', async () => {
const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ success: true })
expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1')
expect(mockDbSet).toHaveBeenCalledWith({
deletedAt: null,
updatedAt: expect.any(Date),
lastSeenAt: expect.any(Date),
})
expect(mockPublishStatusChanged).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
chatId: 'chat-1',
type: 'created',
})
})

it('returns 404 when the chat is restored concurrently before the update lands', async () => {
mockDbReturning.mockResolvedValueOnce([])

const response = await POST(makeRequest('chat-1'), makeContext('chat-1'))
expect(response.status).toBe(404)
expect(mockPublishStatusChanged).not.toHaveBeenCalled()
})
})
Loading
Loading