Skip to content

Commit e15064f

Browse files
Merge remote-tracking branch 'origin/staging' into improvement/table-snapshot-cache
2 parents 9492470 + 8353145 commit e15064f

29 files changed

Lines changed: 17281 additions & 378 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockUpdate, mockSet, mockWhere, mockParseRequest } = vi.hoisted(() => ({
9+
mockUpdate: vi.fn(),
10+
mockSet: vi.fn(),
11+
mockWhere: vi.fn(),
12+
mockParseRequest: vi.fn(),
13+
}))
14+
15+
vi.mock('@sim/db', () => ({
16+
db: { update: mockUpdate },
17+
}))
18+
19+
vi.mock('@sim/db/schema', () => ({
20+
copilotChats: {
21+
id: 'copilotChats.id',
22+
userId: 'copilotChats.userId',
23+
updatedAt: 'copilotChats.updatedAt',
24+
lastSeenAt: 'copilotChats.lastSeenAt',
25+
},
26+
}))
27+
28+
vi.mock('drizzle-orm', () => ({
29+
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
30+
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
31+
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
32+
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
33+
lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })),
34+
sql: vi.fn(() => ({ type: 'sql' })),
35+
}))
36+
37+
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
38+
vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest }))
39+
vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} }))
40+
41+
import { POST } from '@/app/api/mothership/chats/read/route'
42+
43+
function createRequest() {
44+
return new NextRequest('http://localhost:3000/api/mothership/chats/read', {
45+
method: 'POST',
46+
body: JSON.stringify({ chatId: 'chat-1' }),
47+
})
48+
}
49+
50+
describe('POST /api/mothership/chats/read', () => {
51+
beforeEach(() => {
52+
vi.clearAllMocks()
53+
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
54+
userId: 'user-1',
55+
isAuthenticated: true,
56+
})
57+
mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } })
58+
mockWhere.mockResolvedValue(undefined)
59+
mockSet.mockReturnValue({ where: mockWhere })
60+
mockUpdate.mockReturnValue({ set: mockSet })
61+
})
62+
63+
it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => {
64+
const res = await POST(createRequest())
65+
expect(res.status).toBe(200)
66+
67+
expect(mockUpdate).toHaveBeenCalledTimes(1)
68+
const whereArg = mockWhere.mock.calls[0][0] as {
69+
type: string
70+
conditions: Array<{ type: string; conditions?: unknown[] }>
71+
}
72+
expect(whereArg.type).toBe('and')
73+
74+
const orClause = whereArg.conditions.find((c) => c.type === 'or')
75+
expect(orClause).toBeDefined()
76+
expect(orClause?.conditions).toEqual(
77+
expect.arrayContaining([
78+
{ type: 'isNull', field: 'copilotChats.lastSeenAt' },
79+
{ type: 'lt', field: 'copilotChats.lastSeenAt', value: 'copilotChats.updatedAt' },
80+
])
81+
)
82+
})
83+
84+
it('does not touch the database when unauthenticated', async () => {
85+
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
86+
userId: null,
87+
isAuthenticated: false,
88+
})
89+
const res = await POST(createRequest())
90+
expect(res.status).toBe(401)
91+
expect(mockUpdate).not.toHaveBeenCalled()
92+
})
93+
})

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

Lines changed: 8 additions & 2 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, lt, or, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats'
77
import { parseRequest } from '@/lib/api/server'
@@ -28,7 +28,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2828
await db
2929
.update(copilotChats)
3030
.set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
31-
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId)))
31+
.where(
32+
and(
33+
eq(copilotChats.id, chatId),
34+
eq(copilotChats.userId, userId),
35+
or(isNull(copilotChats.lastSeenAt), lt(copilotChats.lastSeenAt, copilotChats.updatedAt))
36+
)
37+
)
3238

3339
return NextResponse.json({ success: true })
3440
} catch (error) {

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

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { db } from '@sim/db'
22
import { copilotChats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, desc, eq } from 'drizzle-orm'
54
import { type NextRequest, NextResponse } from 'next/server'
65
import {
76
createMothershipChatContract,
87
listMothershipChatsContract,
98
} from '@/lib/api/contracts/mothership-chats'
109
import { parseRequest } from '@/lib/api/server'
11-
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
10+
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
1211
import { chatPubSub } from '@/lib/copilot/chat-status'
1312
import {
1413
authenticateCopilotRequestSessionOnly,
@@ -42,35 +41,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4241

4342
await assertActiveWorkspaceAccess(workspaceId, userId)
4443

45-
const chats = await db
46-
.select({
47-
id: copilotChats.id,
48-
title: copilotChats.title,
49-
updatedAt: copilotChats.updatedAt,
50-
activeStreamId: copilotChats.conversationId,
51-
lastSeenAt: copilotChats.lastSeenAt,
52-
pinned: copilotChats.pinned,
53-
})
54-
.from(copilotChats)
55-
.where(
56-
and(
57-
eq(copilotChats.userId, userId),
58-
eq(copilotChats.workspaceId, workspaceId),
59-
eq(copilotChats.type, 'mothership')
60-
)
61-
)
62-
.orderBy(desc(copilotChats.pinned), desc(copilotChats.updatedAt))
63-
64-
const streamMarkers = await reconcileChatStreamMarkers(
65-
chats.map((c) => ({ chatId: c.id, streamId: c.activeStreamId })),
66-
{ repairVerifiedStaleMarkers: true }
67-
)
68-
const reconciled = chats.map((c) => {
69-
const activeStreamId = streamMarkers.get(c.id)?.streamId ?? null
70-
return activeStreamId === c.activeStreamId ? c : { ...c, activeStreamId }
71-
})
44+
const data = await listMothershipChats(userId, workspaceId)
7245

73-
return NextResponse.json({ success: true, data: reconciled })
46+
return NextResponse.json({ success: true, data })
7447
} catch (error) {
7548
if (isWorkspaceAccessDeniedError(error)) {
7649
return createForbiddenResponse('Workspace access denied')

apps/sim/app/api/users/me/profile/route.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
99
import { generateRequestId } from '@/lib/core/utils/request'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { getUserProfile } from '@/lib/users/queries'
1112

1213
const logger = createLogger('UpdateUserProfileAPI')
1314

@@ -84,17 +85,7 @@ export const GET = withRouteHandler(async () => {
8485

8586
const userId = session.user.id
8687

87-
const [userRecord] = await db
88-
.select({
89-
id: user.id,
90-
name: user.name,
91-
email: user.email,
92-
image: user.image,
93-
emailVerified: user.emailVerified,
94-
})
95-
.from(user)
96-
.where(eq(user.id, userId))
97-
.limit(1)
88+
const userRecord = await getUserProfile(userId)
9889

9990
if (!userRecord) {
10091
return NextResponse.json({ error: 'User not found' }, { status: 404 })

apps/sim/app/api/users/me/settings/route.ts

Lines changed: 4 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,93 +2,26 @@ import { db } from '@sim/db'
22
import { settings } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateShortId } from '@sim/utils/id'
5-
import { eq } from 'drizzle-orm'
65
import { type NextRequest, NextResponse } from 'next/server'
76
import { updateUserSettingsContract } from '@/lib/api/contracts'
87
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
98
import { getSession } from '@/lib/auth'
109
import { generateRequestId } from '@/lib/core/utils/request'
1110
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { defaultUserSettings, getUserSettings } from '@/lib/users/queries'
1212

1313
const logger = createLogger('UserSettingsAPI')
1414

15-
const defaultSettings = {
16-
theme: 'system',
17-
autoConnect: true,
18-
telemetryEnabled: true,
19-
emailPreferences: {},
20-
billingUsageNotificationsEnabled: true,
21-
showTrainingControls: false,
22-
superUserModeEnabled: false,
23-
mothershipEnvironment: 'default',
24-
errorNotificationsEnabled: true,
25-
snapToGridSize: 0,
26-
showActionBar: true,
27-
timezone: null,
28-
lastActiveWorkspaceId: null,
29-
}
30-
3115
export const GET = withRouteHandler(async () => {
3216
const requestId = generateRequestId()
3317

3418
try {
3519
const session = await getSession()
36-
37-
if (!session?.user?.id) {
38-
logger.info(`[${requestId}] Returning default settings for unauthenticated user`)
39-
return NextResponse.json({ data: defaultSettings }, { status: 200 })
40-
}
41-
42-
const userId = session.user.id
43-
const result = await db
44-
.select({
45-
theme: settings.theme,
46-
autoConnect: settings.autoConnect,
47-
telemetryEnabled: settings.telemetryEnabled,
48-
emailPreferences: settings.emailPreferences,
49-
billingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
50-
showTrainingControls: settings.showTrainingControls,
51-
superUserModeEnabled: settings.superUserModeEnabled,
52-
mothershipEnvironment: settings.mothershipEnvironment,
53-
errorNotificationsEnabled: settings.errorNotificationsEnabled,
54-
snapToGridSize: settings.snapToGridSize,
55-
showActionBar: settings.showActionBar,
56-
timezone: settings.timezone,
57-
lastActiveWorkspaceId: settings.lastActiveWorkspaceId,
58-
})
59-
.from(settings)
60-
.where(eq(settings.userId, userId))
61-
.limit(1)
62-
63-
if (!result.length) {
64-
return NextResponse.json({ data: defaultSettings }, { status: 200 })
65-
}
66-
67-
const userSettings = result[0]
68-
69-
return NextResponse.json(
70-
{
71-
data: {
72-
theme: userSettings.theme,
73-
autoConnect: userSettings.autoConnect,
74-
telemetryEnabled: userSettings.telemetryEnabled,
75-
emailPreferences: userSettings.emailPreferences ?? {},
76-
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
77-
showTrainingControls: userSettings.showTrainingControls ?? false,
78-
superUserModeEnabled: userSettings.superUserModeEnabled ?? false,
79-
mothershipEnvironment: userSettings.mothershipEnvironment ?? 'default',
80-
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
81-
snapToGridSize: userSettings.snapToGridSize ?? 0,
82-
showActionBar: userSettings.showActionBar ?? true,
83-
timezone: userSettings.timezone ?? null,
84-
lastActiveWorkspaceId: userSettings.lastActiveWorkspaceId ?? null,
85-
},
86-
},
87-
{ status: 200 }
88-
)
20+
const data = await getUserSettings(session?.user?.id ?? null)
21+
return NextResponse.json({ data }, { status: 200 })
8922
} catch (error: any) {
9023
logger.error(`[${requestId}] Settings fetch error`, error)
91-
return NextResponse.json({ data: defaultSettings }, { status: 200 })
24+
return NextResponse.json({ data: defaultUserSettings }, { status: 200 })
9225
}
9326
})
9427

apps/sim/app/api/v1/admin/audit-logs/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* Response: AdminListResponse<AdminAuditLog>
1919
*/
2020

21-
import { db } from '@sim/db'
21+
import { dbReplica } from '@sim/db'
2222
import { auditLog } from '@sim/db/schema'
2323
import { createLogger } from '@sim/logger'
2424
import { and, count, desc } from 'drizzle-orm'
@@ -70,8 +70,8 @@ export const GET = withRouteHandler(
7070
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
7171

7272
const [countResult, logs] = await Promise.all([
73-
db.select({ total: count() }).from(auditLog).where(whereClause),
74-
db
73+
dbReplica.select({ total: count() }).from(auditLog).where(whereClause),
74+
dbReplica
7575
.select()
7676
.from(auditLog)
7777
.where(whereClause)

apps/sim/app/api/v1/admin/organizations/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* Response: AdminSingleResponse<AdminOrganization & { memberId: string }>
2222
*/
2323

24-
import { db } from '@sim/db'
24+
import { db, dbReplica } from '@sim/db'
2525
import { member, organization, user } from '@sim/db/schema'
2626
import { createLogger } from '@sim/logger'
2727
import { count, eq } from 'drizzle-orm'
@@ -70,8 +70,8 @@ export const GET = withRouteHandler(
7070

7171
try {
7272
const [countResult, organizations] = await Promise.all([
73-
db.select({ total: count() }).from(organization),
74-
db
73+
dbReplica.select({ total: count() }).from(organization),
74+
dbReplica
7575
.select({
7676
id: organization.id,
7777
name: organization.name,

0 commit comments

Comments
 (0)