Skip to content

Commit 0be1f38

Browse files
authored
fix(workspaces): persist visit recency server-side so sidebar order is stable on refresh (#8284)
* fix(workspaces): persist visit recency server-side so sidebar order is stable on refresh * fix(workspaces): serialize visits and stamp them with the database clock * chore(workspaces): drop leftover churn and mark the legacy last-active column for contract * chore(db): drop contract marker on settings.last_active_workspace_id * chore(workspaces): write the permission-group exemption as TSDoc
1 parent 55b1a5f commit 0be1f38

24 files changed

Lines changed: 29974 additions & 281 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
getSession: vi.fn(),
9+
role: vi.fn(),
10+
context: vi.fn(),
11+
record: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
15+
vi.mock('@sim/platform-authz/workspace', async (importOriginal) => ({
16+
...(await importOriginal<typeof import('@sim/platform-authz/workspace')>()),
17+
resolveEffectiveWorkspacePermission: mocks.role,
18+
}))
19+
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
20+
resolveActiveWorkspaceApplicationContext: mocks.context,
21+
}))
22+
vi.mock('@/lib/workspaces/visits', () => ({ recordWorkspaceVisitRecord: mocks.record }))
23+
24+
import { OrchestrationError } from '@/lib/core/orchestration/types'
25+
import { POST } from '@/app/api/workspaces/[id]/visit/route'
26+
27+
const routeContext = { params: Promise.resolve({ id: 'ws-1' }) }
28+
29+
describe('POST /api/workspaces/[id]/visit', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks()
32+
mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
33+
mocks.context.mockImplementation(async (workspaceId: string) => ({
34+
workspaceId,
35+
workspaceOrganizationId: null,
36+
allowPersonalApiKeys: true,
37+
}))
38+
mocks.role.mockResolvedValue('read')
39+
mocks.record.mockResolvedValue(undefined)
40+
})
41+
42+
it('401s without a session and records nothing', async () => {
43+
mocks.getSession.mockResolvedValue(null)
44+
45+
const res = await POST(createMockRequest('POST'), routeContext)
46+
47+
expect(res.status).toBe(401)
48+
expect(mocks.record).not.toHaveBeenCalled()
49+
})
50+
51+
it('records the visit for a workspace member', async () => {
52+
const res = await POST(createMockRequest('POST'), routeContext)
53+
54+
expect(res.status).toBe(200)
55+
await expect(res.json()).resolves.toEqual({ success: true })
56+
expect(mocks.record).toHaveBeenCalledWith('user-1', 'ws-1')
57+
})
58+
59+
it('answers 404 for a workspace outside the caller reach, same as a missing one', async () => {
60+
mocks.role.mockResolvedValue(null)
61+
const denied = await POST(createMockRequest('POST'), routeContext)
62+
63+
mocks.context.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found'))
64+
const missing = await POST(createMockRequest('POST'), routeContext)
65+
66+
expect(denied.status).toBe(404)
67+
expect(missing.status).toBe(404)
68+
expect(await denied.json()).toEqual(await missing.json())
69+
expect(mocks.record).not.toHaveBeenCalled()
70+
})
71+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { recordWorkspaceVisitContract } from '@/lib/api/contracts/workspaces'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalWorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies'
8+
import {
9+
recordWorkspaceVisit,
10+
workspaceVisitOperations,
11+
} from '@/lib/workspaces/application/record-workspace-visit'
12+
13+
export const POST = defineInternalJsonRoute({
14+
contract: recordWorkspaceVisitContract,
15+
auth: internalSessionAuth,
16+
operation: workspaceVisitOperations.record,
17+
rateLimit: internalRateLimits.none({
18+
reason: 'One idempotent upsert per workspace page load, replacing the settings write it made.',
19+
}),
20+
errorPolicy: internalWorkspaceErrorPolicies.concealWorkspaceAuthorization,
21+
mapInput: ({ params }) => ({ workspaceId: params.id }),
22+
useCase: recordWorkspaceVisit,
23+
present: () => ({ success: true as const }),
24+
})

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.test.tsx‎

Lines changed: 57 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,23 @@
22
* @vitest-environment jsdom
33
*/
44
import { act } from 'react'
5-
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6+
import { hydrateRoot, type Root } from 'react-dom/client'
67
import { renderToString } from 'react-dom/server'
78
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8-
import { STORAGE_KEYS, WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
99

10-
const { mockUseWorkspacesQuery, pins } = vi.hoisted(() => ({
11-
pins: { current: new Set<string>() },
12-
mockUseWorkspacesQuery: vi.fn(),
13-
}))
14-
15-
vi.mock('@/hooks/queries/workspace', () => ({
16-
useWorkspacesQuery: mockUseWorkspacesQuery,
17-
EMPTY_PINNED_WORKSPACE_IDS: new Set<string>(),
18-
usePinnedWorkspaceIds: () => ({ data: pins.current }),
19-
}))
10+
vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn() }))
2011

2112
import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces'
13+
import { workspaceKeys } from '@/hooks/queries/workspace'
14+
15+
/** In the order the server returns them: most recently visited first. */
16+
const workspaces = [
17+
{ id: 'recent', organizationId: 'org-1' },
18+
{ id: 'other-org', organizationId: 'org-2' },
19+
{ id: 'earlier', organizationId: 'org-1' },
20+
{ id: 'unvisited', organizationId: 'org-1' },
21+
]
2222

2323
function Harness() {
2424
const { workspaces } = useOrganizationWorkspaces('org-1')
@@ -31,22 +31,23 @@ function Harness() {
3131
)
3232
}
3333

34+
/** A client holding the list exactly as the layout prefetch hydrates it. */
35+
function seededClient(pinnedWorkspaceIds: string[]) {
36+
const queryClient = new QueryClient()
37+
queryClient.setQueryData(workspaceKeys.list('active'), {
38+
workspaces,
39+
lastActiveWorkspaceId: null,
40+
pinnedWorkspaceIds,
41+
creationPolicy: null,
42+
})
43+
return queryClient
44+
}
45+
3446
let container: HTMLDivElement
3547
let root: Root | undefined
3648

3749
beforeEach(() => {
3850
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
39-
localStorage.clear()
40-
pins.current = new Set()
41-
mockUseWorkspacesQuery.mockReturnValue({
42-
data: [
43-
{ id: 'newest', organizationId: 'org-1' },
44-
{ id: 'other-org', organizationId: 'org-2' },
45-
{ id: 'older', organizationId: 'org-1' },
46-
{ id: 'oldest', organizationId: 'org-1' },
47-
],
48-
isLoading: false,
49-
})
5051
container = document.createElement('div')
5152
document.body.appendChild(container)
5253
})
@@ -55,69 +56,51 @@ afterEach(async () => {
5556
if (root) await act(async () => root?.unmount())
5657
root = undefined
5758
container.remove()
58-
localStorage.clear()
5959
vi.unstubAllGlobals()
6060
})
6161

6262
function workspaceIds() {
6363
return Array.from(container.querySelectorAll('li'), (item) => item.textContent)
6464
}
6565

66-
describe('useOrganizationWorkspaces', () => {
67-
it('hydrates the prefetched order before applying visit history without changing the query cache', async () => {
68-
localStorage.setItem(
69-
STORAGE_KEYS.WORKSPACE_RECENCY,
70-
JSON.stringify({ oldest: 100, older: 200, 'other-org': 300 })
66+
/** Server-renders, hydrates, and fails if hydration changed a single DOM node. */
67+
async function renderAndHydrate(pinnedWorkspaceIds: string[] = []) {
68+
container.innerHTML = renderToString(
69+
<QueryClientProvider client={seededClient(pinnedWorkspaceIds)}>
70+
<Harness />
71+
</QueryClientProvider>
72+
)
73+
const serverOrder = workspaceIds()
74+
const onRecoverableError = vi.fn()
75+
const mutations: MutationRecord[] = []
76+
const observer = new MutationObserver((records) => mutations.push(...records))
77+
observer.observe(container, { childList: true, subtree: true, characterData: true })
78+
await act(async () => {
79+
root = hydrateRoot(
80+
container,
81+
<QueryClientProvider client={seededClient(pinnedWorkspaceIds)}>
82+
<Harness />
83+
</QueryClientProvider>,
84+
{ onRecoverableError }
7185
)
72-
container.innerHTML = renderToString(<Harness />)
73-
expect(workspaceIds()).toEqual(['newest', 'older', 'oldest'])
74-
75-
const onRecoverableError = vi.fn()
76-
await act(async () => {
77-
root = hydrateRoot(container, <Harness />, { onRecoverableError })
78-
})
79-
80-
expect(onRecoverableError).not.toHaveBeenCalled()
81-
expect(workspaceIds()).toEqual(['older', 'oldest', 'newest'])
82-
expect(mockUseWorkspacesQuery().data.map(({ id }: { id: string }) => id)).toEqual([
83-
'newest',
84-
'other-org',
85-
'older',
86-
'oldest',
87-
])
8886
})
87+
observer.disconnect()
88+
expect(onRecoverableError).not.toHaveBeenCalled()
89+
expect(mutations).toEqual([])
90+
return serverOrder
91+
}
8992

90-
it('preserves creation-date order when the browser has no visit history', async () => {
91-
await act(async () => {
92-
root = createRoot(container)
93-
root.render(<Harness />)
94-
})
95-
96-
expect(workspaceIds()).toEqual(['newest', 'older', 'oldest'])
93+
describe('useOrganizationWorkspaces', () => {
94+
it('renders the server visit order so hydration never reshuffles rows', async () => {
95+
expect(await renderAndHydrate()).toEqual(['recent', 'earlier', 'unvisited'])
9796
})
98-
it('keeps pins first and reacts to visits without mutating the query cache', async () => {
99-
pins.current = new Set(['oldest'])
100-
await act(async () => {
101-
root = createRoot(container)
102-
root.render(<Harness />)
103-
})
104-
expect(workspaceIds()).toEqual(['oldest', 'newest', 'older'])
105-
await act(async () => WorkspaceRecencyStorage.touch('older'))
106-
expect(workspaceIds()).toEqual(['oldest', 'older', 'newest'])
107-
pins.current = new Set()
108-
await act(async () => root?.render(<Harness />))
109-
expect(workspaceIds()).toEqual(['older', 'newest', 'oldest'])
97+
98+
it('lifts pins above the visit order', async () => {
99+
expect(await renderAndHydrate(['unvisited'])).toEqual(['unvisited', 'recent', 'earlier'])
110100
})
111101

112-
it('follows visit history changed by another tab', async () => {
113-
await act(async () => {
114-
root = createRoot(container)
115-
root.render(<Harness />)
116-
})
117-
await act(async () => {
118-
localStorage.setItem(STORAGE_KEYS.WORKSPACE_RECENCY, JSON.stringify({ oldest: 300 }))
119-
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEYS.WORKSPACE_RECENCY }))
120-
})
121-
expect(workspaceIds()).toEqual(['oldest', 'newest', 'older'])
102+
it('does not reorder the cached list', async () => {
103+
await renderAndHydrate(['unvisited'])
104+
expect(workspaces.map(({ id }) => id)).toEqual(['recent', 'other-org', 'earlier', 'unvisited'])
122105
})
123106
})

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,18 @@
11
import {
22
EMPTY_PINNED_WORKSPACE_IDS,
3+
useOrderedWorkspacesQuery,
34
usePinnedWorkspaceIds,
4-
useWorkspacesQuery,
55
} from '@/hooks/queries/workspace'
6-
import { useWorkspaceOrder } from '@/hooks/use-workspace-order'
76

87
/**
98
* The organization's workspaces the viewer belongs to, for the sidebar's
10-
* Workspaces section. Read from the viewer's workspace list — the same query the
11-
* workspace switcher uses — narrowed to those the organization owns.
9+
* Workspaces section. Read from the viewer's workspace list — the same query and
10+
* order the workspace switcher uses — narrowed to those the organization owns.
1211
*/
1312
export function useOrganizationWorkspaces(organizationId: string) {
14-
const { data = [], isLoading } = useWorkspacesQuery()
13+
const { data = [], isLoading } = useOrderedWorkspacesQuery()
1514
const { data: pinnedWorkspaceIds = EMPTY_PINNED_WORKSPACE_IDS } = usePinnedWorkspaceIds()
16-
const orderedWorkspaces = useWorkspaceOrder(data, pinnedWorkspaceIds)
17-
const workspaces = orderedWorkspaces.filter(
18-
(workspace) => workspace.organizationId === organizationId
19-
)
15+
const workspaces = data.filter((workspace) => workspace.organizationId === organizationId)
2016

2117
return {
2218
workspaces,

‎apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const {
99
mockPush,
10-
mockRequestJson,
10+
mockRecordWorkspaceVisit,
1111
mockSwitchToWorkspace,
12-
mockUseWorkspacesQuery,
12+
mockUseOrderedWorkspacesQuery,
1313
mockUseWorkspaceCreationPolicy,
1414
} = vi.hoisted(() => ({
1515
mockPush: vi.fn(),
16-
mockRequestJson: vi.fn(),
16+
mockRecordWorkspaceVisit: vi.fn(),
1717
mockSwitchToWorkspace: vi.fn(),
18-
mockUseWorkspacesQuery: vi.fn(),
18+
mockUseOrderedWorkspacesQuery: vi.fn(),
1919
mockUseWorkspaceCreationPolicy: vi.fn(),
2020
}))
2121

@@ -24,10 +24,6 @@ vi.mock('next/navigation', () => ({
2424
useRouter: () => ({ push: mockPush }),
2525
}))
2626

27-
vi.mock('@/lib/api/client/request', () => ({
28-
requestJson: mockRequestJson,
29-
}))
30-
3127
vi.mock('@/hooks/queries/invitations', () => ({
3228
useLeaveWorkspace: () => ({ isPending: false, mutateAsync: vi.fn() }),
3329
}))
@@ -37,11 +33,12 @@ vi.mock('@/hooks/queries/workspace', () => ({
3733
useDeleteWorkspace: () => ({ isPending: false, mutateAsync: vi.fn() }),
3834
useUpdateWorkspace: () => ({ mutateAsync: vi.fn() }),
3935
useWorkspaceCreationPolicy: mockUseWorkspaceCreationPolicy,
40-
useWorkspacesQuery: mockUseWorkspacesQuery,
36+
useOrderedWorkspacesQuery: mockUseOrderedWorkspacesQuery,
4137
/** No pins: this suite is about the deep-link guard, not switcher ordering. */
4238
EMPTY_PINNED_WORKSPACE_IDS: new Set<string>(),
4339
usePinnedWorkspaceIds: () => ({ data: new Set<string>() }),
4440
useToggleWorkspacePin: () => ({ mutate: vi.fn() }),
41+
useRecordWorkspaceVisit: () => ({ mutate: mockRecordWorkspaceVisit }),
4542
}))
4643

4744
vi.mock('@/stores/workflows/registry/store', () => ({
@@ -97,8 +94,8 @@ describe('resolveWorkspaceSwitchHref', () => {
9794
})
9895
})
9996

100-
function Harness() {
101-
useWorkspaceManagement({ workspaceId: 'workspace-denied', sessionUserId: 'user-1' })
97+
function Harness({ sessionUserId = 'user-1' }: { sessionUserId?: string }) {
98+
useWorkspaceManagement({ workspaceId: 'workspace-denied', sessionUserId })
10299
return null
103100
}
104101

@@ -110,8 +107,7 @@ describe('useWorkspaceManagement direct access guard', () => {
110107
container = document.createElement('div')
111108
document.body.appendChild(container)
112109
root = createRoot(container)
113-
localStorage.clear()
114-
mockUseWorkspacesQuery.mockReturnValue({
110+
mockUseOrderedWorkspacesQuery.mockReturnValue({
115111
data: [
116112
{
117113
id: 'workspace-accessible',
@@ -141,4 +137,20 @@ describe('useWorkspaceManagement direct access guard', () => {
141137

142138
expect(mockPush).not.toHaveBeenCalled()
143139
})
140+
141+
it('records the visit once for the workspace in the URL', async () => {
142+
await act(async () => root.render(<Harness />))
143+
await act(async () => root.render(<Harness />))
144+
145+
expect(mockRecordWorkspaceVisit).toHaveBeenCalledTimes(1)
146+
expect(mockRecordWorkspaceVisit).toHaveBeenCalledWith('workspace-denied')
147+
})
148+
149+
it('waits for the session before recording the visit', async () => {
150+
await act(async () => root.render(<Harness sessionUserId='' />))
151+
expect(mockRecordWorkspaceVisit).not.toHaveBeenCalled()
152+
153+
await act(async () => root.render(<Harness />))
154+
expect(mockRecordWorkspaceVisit).toHaveBeenCalledTimes(1)
155+
})
144156
})

0 commit comments

Comments
 (0)