Skip to content

Commit 78976fc

Browse files
committed
feat(chat): soft-delete sidebar chats with restore from Recently Deleted
1 parent e3f9deb commit 78976fc

30 files changed

Lines changed: 17763 additions & 70 deletions

File tree

apps/sim/app/api/copilot/chat/queries.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
55
import { toError } from '@sim/utils/errors'
6-
import { and, desc, eq } from 'drizzle-orm'
6+
import { and, desc, eq, isNull } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
99
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
@@ -189,7 +189,13 @@ export async function GET(req: NextRequest) {
189189
updatedAt: copilotChats.updatedAt,
190190
})
191191
.from(copilotChats)
192-
.where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter))
192+
.where(
193+
and(
194+
eq(copilotChats.userId, authenticatedUserId),
195+
isNull(copilotChats.deletedAt),
196+
scopeFilter
197+
)
198+
)
193199
.orderBy(desc(copilotChats.updatedAt))
194200

195201
const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}`

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, sql } from 'drizzle-orm'
4+
import { and, eq, isNull, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import {
77
addCopilotChatResourceContract,
@@ -64,7 +64,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
6464
const [chat] = await db
6565
.select({ resources: copilotChats.resources })
6666
.from(copilotChats)
67-
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
67+
.where(
68+
and(
69+
eq(copilotChats.id, chatId),
70+
eq(copilotChats.userId, userId),
71+
isNull(copilotChats.deletedAt)
72+
)
73+
)
6874
.limit(1)
6975

7076
if (!chat) {
@@ -91,7 +97,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
9197
await db
9298
.update(copilotChats)
9399
.set({ resources: sql`${JSON.stringify(merged)}::jsonb`, updatedAt: new Date() })
94-
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
100+
.where(
101+
and(
102+
eq(copilotChats.id, chatId),
103+
eq(copilotChats.userId, userId),
104+
isNull(copilotChats.deletedAt)
105+
)
106+
)
95107

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

@@ -124,7 +136,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
124136
const [chat] = await db
125137
.select({ resources: copilotChats.resources })
126138
.from(copilotChats)
127-
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
139+
.where(
140+
and(
141+
eq(copilotChats.id, chatId),
142+
eq(copilotChats.userId, userId),
143+
isNull(copilotChats.deletedAt)
144+
)
145+
)
128146
.limit(1)
129147

130148
if (!chat) {
@@ -142,7 +160,13 @@ export const PATCH = withRouteHandler(async (req: NextRequest) => {
142160
await db
143161
.update(copilotChats)
144162
.set({ resources: sql`${JSON.stringify(newOrder)}::jsonb`, updatedAt: new Date() })
145-
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
163+
.where(
164+
and(
165+
eq(copilotChats.id, chatId),
166+
eq(copilotChats.userId, userId),
167+
isNull(copilotChats.deletedAt)
168+
)
169+
)
146170

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

@@ -182,7 +206,13 @@ export const DELETE = withRouteHandler(async (req: NextRequest) => {
182206
), '[]'::jsonb)`,
183207
updatedAt: new Date(),
184208
})
185-
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
209+
.where(
210+
and(
211+
eq(copilotChats.id, chatId),
212+
eq(copilotChats.userId, userId),
213+
isNull(copilotChats.deletedAt)
214+
)
215+
)
186216
.returning({ resources: copilotChats.resources })
187217

188218
if (!updated) {

apps/sim/app/api/copilot/chat/update-messages/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ vi.mock('@/lib/copilot/chat/messages-store', () => ({
4646
vi.mock('drizzle-orm', () => ({
4747
and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
4848
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
49+
isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
4950
}))
5051

5152
import { POST } from '@/app/api/copilot/chat/update-messages/route'

apps/sim/app/api/copilot/chats/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const GET = withRouteHandler(async (_request: NextRequest) => {
6262
.where(
6363
and(
6464
eq(copilotChats.userId, userId),
65+
isNull(copilotChats.deletedAt),
6566
or(
6667
and(isNull(copilotChats.workflowId), isNull(copilotChats.workspaceId)),
6768
inAccessibleWorkspace

apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,18 @@ vi.mock('@sim/db/schema', () => ({
7070
previewYaml: 'copilotChats.previewYaml',
7171
planArtifact: 'copilotChats.planArtifact',
7272
config: 'copilotChats.config',
73+
deletedAt: 'copilotChats.deletedAt',
7374
},
7475
workspaceFiles: {
7576
id: 'workspaceFiles.id',
7677
},
7778
}))
7879

7980
vi.mock('drizzle-orm', () => ({
81+
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
8082
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
8183
inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })),
84+
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
8285
}))
8386

8487
vi.mock('@/lib/copilot/resources/persistence', () => ({

apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22
import { copilotChats, workspaceFiles } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
5-
import { eq, inArray } from 'drizzle-orm'
5+
import { and, eq, inArray, isNull } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
88
import { parseRequest } from '@/lib/api/server'
@@ -84,7 +84,7 @@ export const POST = withRouteHandler(
8484
config: copilotChats.config,
8585
})
8686
.from(copilotChats)
87-
.where(eq(copilotChats.id, chatId))
87+
.where(and(eq(copilotChats.id, chatId), isNull(copilotChats.deletedAt)))
8888
.limit(1)
8989

9090
if (!parent || parent.userId !== userId || parent.type !== 'mothership') {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { db } from '@sim/db'
2+
import { copilotChats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, isNotNull } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { restoreMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { chatPubSub } from '@/lib/copilot/chat-status'
9+
import {
10+
authenticateCopilotRequestSessionOnly,
11+
createInternalServerErrorResponse,
12+
createUnauthorizedResponse,
13+
} from '@/lib/copilot/request/http'
14+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
import { captureServerEvent } from '@/lib/posthog/server'
16+
17+
const logger = createLogger('RestoreMothershipChatAPI')
18+
19+
/**
20+
* POST /api/mothership/chats/[chatId]/restore
21+
* Restores a soft-deleted mothership chat back into the sidebar. Ownership is
22+
* enforced by scoping the update to the authenticated user's rows, matching
23+
* the delete path.
24+
*/
25+
export const POST = withRouteHandler(
26+
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
27+
try {
28+
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
29+
if (!isAuthenticated || !userId) {
30+
return createUnauthorizedResponse()
31+
}
32+
33+
const parsed = await parseRequest(restoreMothershipChatContract, request, context)
34+
if (!parsed.success) return parsed.response
35+
const { chatId } = parsed.data.params
36+
37+
const [restoredChat] = await db
38+
.update(copilotChats)
39+
.set({ deletedAt: null })
40+
.where(
41+
and(
42+
eq(copilotChats.id, chatId),
43+
eq(copilotChats.userId, userId),
44+
eq(copilotChats.type, 'mothership'),
45+
isNotNull(copilotChats.deletedAt)
46+
)
47+
)
48+
.returning({
49+
workspaceId: copilotChats.workspaceId,
50+
})
51+
52+
if (!restoredChat) {
53+
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
54+
}
55+
56+
if (restoredChat.workspaceId) {
57+
chatPubSub?.publishStatusChanged({
58+
workspaceId: restoredChat.workspaceId,
59+
chatId,
60+
type: 'created',
61+
})
62+
captureServerEvent(
63+
userId,
64+
'task_restored',
65+
{ workspace_id: restoredChat.workspaceId },
66+
{
67+
groups: { workspace: restoredChat.workspaceId },
68+
}
69+
)
70+
}
71+
72+
return NextResponse.json({ success: true })
73+
} catch (error) {
74+
logger.error('Error restoring mothership chat:', error)
75+
return createInternalServerErrorResponse('Failed to restore chat')
76+
}
77+
}
78+
)

apps/sim/app/api/mothership/chats/[chatId]/route.test.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const {
9-
mockDbDelete,
9+
mockDbUpdate,
10+
mockDbSet,
1011
mockDbReturning,
1112
mockDbWhere,
1213
mockDecrementStorageUsageForBillingContext,
@@ -17,7 +18,8 @@ const {
1718
mockReadFilePreviewSessions,
1819
mockGetLatestRunForStream,
1920
} = vi.hoisted(() => ({
20-
mockDbDelete: vi.fn(),
21+
mockDbUpdate: vi.fn(),
22+
mockDbSet: vi.fn(),
2123
mockDbReturning: vi.fn(),
2224
mockDbWhere: vi.fn(),
2325
mockDecrementStorageUsageForBillingContext: vi.fn(),
@@ -31,7 +33,7 @@ const {
3133

3234
vi.mock('@sim/db', () => ({
3335
db: {
34-
delete: mockDbDelete,
36+
update: mockDbUpdate,
3537
},
3638
}))
3739

@@ -43,12 +45,14 @@ vi.mock('@sim/db/schema', () => ({
4345
updatedAt: 'copilotChats.updatedAt',
4446
lastSeenAt: 'copilotChats.lastSeenAt',
4547
workspaceId: 'copilotChats.workspaceId',
48+
deletedAt: 'copilotChats.deletedAt',
4649
},
4750
}))
4851

4952
vi.mock('drizzle-orm', () => ({
5053
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
5154
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
55+
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
5256
sql: Object.assign(
5357
vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
5458
type: 'sql',
@@ -319,12 +323,13 @@ describe('DELETE /api/mothership/chats/[chatId]', () => {
319323
type: 'mothership',
320324
workspaceId: 'workspace-1',
321325
})
322-
mockDbDelete.mockReturnValue({ where: mockDbWhere })
326+
mockDbUpdate.mockReturnValue({ set: mockDbSet })
327+
mockDbSet.mockReturnValue({ where: mockDbWhere })
323328
mockDbWhere.mockReturnValue({ returning: mockDbReturning })
324329
mockDbReturning.mockResolvedValue([{ workspaceId: 'workspace-1' }])
325330
})
326331

327-
it('deletes an unbilled chat without decrementing workspace or payer storage', async () => {
332+
it('soft-deletes an unbilled chat without decrementing workspace or payer storage', async () => {
328333
const response = await DELETE(
329334
new NextRequest('http://localhost:3000/api/mothership/chats/chat-delete', {
330335
method: 'DELETE',
@@ -333,7 +338,8 @@ describe('DELETE /api/mothership/chats/[chatId]', () => {
333338
)
334339

335340
expect(response.status).toBe(200)
336-
expect(mockDbDelete).toHaveBeenCalled()
341+
expect(mockDbUpdate).toHaveBeenCalled()
342+
expect(mockDbSet).toHaveBeenCalledWith({ deletedAt: expect.any(Date) })
337343
expect(mockDecrementStorageUsageForBillingContext).not.toHaveBeenCalled()
338344
expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
339345
})

apps/sim/app/api/mothership/chats/[chatId]/route.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db } from '@sim/db'
22
import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
5-
import { and, eq, sql } from 'drizzle-orm'
5+
import { and, eq, isNull, sql } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import {
88
deleteMothershipChatContract,
@@ -188,7 +188,8 @@ export const PATCH = withRouteHandler(
188188
and(
189189
eq(copilotChats.id, chatId),
190190
eq(copilotChats.userId, userId),
191-
eq(copilotChats.type, 'mothership')
191+
eq(copilotChats.type, 'mothership'),
192+
isNull(copilotChats.deletedAt)
192193
)
193194
)
194195
.returning({
@@ -264,12 +265,14 @@ export const DELETE = withRouteHandler(
264265
}
265266

266267
const [deletedChat] = await db
267-
.delete(copilotChats)
268+
.update(copilotChats)
269+
.set({ deletedAt: new Date() })
268270
.where(
269271
and(
270272
eq(copilotChats.id, chatId),
271273
eq(copilotChats.userId, userId),
272-
eq(copilotChats.type, 'mothership')
274+
eq(copilotChats.type, 'mothership'),
275+
isNull(copilotChats.deletedAt)
273276
)
274277
)
275278
.returning({

apps/sim/app/api/mothership/chats/route.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@ vi.mock('@sim/db/schema', () => ({
3131
updatedAt: 'copilotChats.updatedAt',
3232
conversationId: 'copilotChats.conversationId',
3333
lastSeenAt: 'copilotChats.lastSeenAt',
34+
pinned: 'copilotChats.pinned',
35+
deletedAt: 'copilotChats.deletedAt',
3436
},
3537
}))
3638

3739
vi.mock('drizzle-orm', () => ({
3840
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
3941
desc: vi.fn((field: unknown) => ({ type: 'desc', field })),
4042
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
43+
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
44+
isNotNull: vi.fn((field: unknown) => ({ type: 'isNotNull', field })),
4145
}))
4246

4347
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)

0 commit comments

Comments
 (0)