Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/sim/app/api/copilot/chat/delete/route.ts
Original file line number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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
3 changes: 3 additions & 0 deletions apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts
Original file line number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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