Skip to content

Commit de595f4

Browse files
committed
Merge remote-tracking branch 'origin/staging' into realtime-rooms
# Conflicts: # apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx # packages/db/migrations/meta/0275_snapshot.json # packages/db/migrations/meta/_journal.json # scripts/check-api-validation-contracts.ts
2 parents 11747bd + 9edf892 commit de595f4

65 files changed

Lines changed: 41737 additions & 410 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,11 @@ jobs:
222222
build:
223223
name: Build App
224224
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
225-
timeout-minutes: 15
225+
# Build durations crossed 15 minutes as the app grew (10m02 on Jul 29 AM,
226+
# 14m44 after the folders/desktop/library merges, then two straight
227+
# timeouts) — GitHub reports a job timeout as "cancelled". 25 keeps
228+
# headroom without masking a genuine hang.
229+
timeout-minutes: 25
226230

227231
steps:
228232
- name: Checkout code
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { deleteTableViewContract, updateTableViewContract } from '@/lib/api/contracts/tables'
4+
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import type { TableSchema } from '@/lib/table'
9+
import { deleteTableView, TableViewValidationError, updateTableView } from '@/lib/table'
10+
import { accessError, checkAccess } from '@/app/api/table/utils'
11+
12+
const logger = createLogger('TableViewAPI')
13+
14+
interface TableViewRouteParams {
15+
params: Promise<{ tableId: string; viewId: string }>
16+
}
17+
18+
/** PATCH /api/table/[tableId]/views/[viewId] - Rename, overwrite config, or set as default. */
19+
export const PATCH = withRouteHandler(
20+
async (request: NextRequest, context: TableViewRouteParams) => {
21+
const requestId = generateRequestId()
22+
23+
try {
24+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
25+
if (!authResult.success || !authResult.userId) {
26+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
27+
}
28+
29+
const parsed = await parseRequest(updateTableViewContract, request, context, {
30+
validationErrorResponse: (error) => validationErrorResponse(error),
31+
})
32+
if (!parsed.success) return parsed.response
33+
34+
const { tableId, viewId } = parsed.data.params
35+
const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body
36+
37+
const result = await checkAccess(tableId, authResult.userId, 'write')
38+
if (!result.ok) return accessError(result, requestId, tableId)
39+
40+
if (result.table.workspaceId !== workspaceId) {
41+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
42+
}
43+
44+
const columns = (result.table.schema as TableSchema).columns ?? []
45+
const view = await updateTableView({
46+
viewId,
47+
tableId,
48+
name,
49+
config,
50+
configPatch,
51+
isDefault,
52+
columns,
53+
})
54+
if (!view) {
55+
return NextResponse.json({ error: 'View not found' }, { status: 404 })
56+
}
57+
58+
return NextResponse.json({ success: true, data: { view } })
59+
} catch (error) {
60+
if (error instanceof TableViewValidationError) {
61+
return NextResponse.json({ error: error.message }, { status: 400 })
62+
}
63+
logger.error(`[${requestId}] Error updating table view:`, error)
64+
return NextResponse.json({ error: 'Failed to update view' }, { status: 500 })
65+
}
66+
}
67+
)
68+
69+
/** DELETE /api/table/[tableId]/views/[viewId] - Remove a saved view. */
70+
export const DELETE = withRouteHandler(
71+
async (request: NextRequest, context: TableViewRouteParams) => {
72+
const requestId = generateRequestId()
73+
74+
try {
75+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
76+
if (!authResult.success || !authResult.userId) {
77+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
78+
}
79+
80+
const parsed = await parseRequest(deleteTableViewContract, request, context, {
81+
validationErrorResponse: (error) => validationErrorResponse(error),
82+
})
83+
if (!parsed.success) return parsed.response
84+
85+
const { tableId, viewId } = parsed.data.params
86+
const { workspaceId } = parsed.data.body
87+
88+
const result = await checkAccess(tableId, authResult.userId, 'write')
89+
if (!result.ok) return accessError(result, requestId, tableId)
90+
91+
if (result.table.workspaceId !== workspaceId) {
92+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
93+
}
94+
95+
const deleted = await deleteTableView(viewId, tableId)
96+
if (!deleted) {
97+
return NextResponse.json({ error: 'View not found' }, { status: 404 })
98+
}
99+
100+
return NextResponse.json({ success: true, data: { deleted: true } })
101+
} catch (error) {
102+
logger.error(`[${requestId}] Error deleting table view:`, error)
103+
return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 })
104+
}
105+
}
106+
)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { createTableViewContract, listTableViewsContract } from '@/lib/api/contracts/tables'
4+
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import type { TableSchema } from '@/lib/table'
9+
import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table'
10+
import { accessError, checkAccess } from '@/app/api/table/utils'
11+
12+
const logger = createLogger('TableViewsAPI')
13+
14+
interface TableRouteParams {
15+
params: Promise<{ tableId: string }>
16+
}
17+
18+
/** GET /api/table/[tableId]/views - List every saved view on a table. */
19+
export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
20+
const requestId = generateRequestId()
21+
22+
try {
23+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
24+
if (!authResult.success || !authResult.userId) {
25+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
26+
}
27+
28+
const parsed = await parseRequest(listTableViewsContract, request, context, {
29+
validationErrorResponse: (error) => validationErrorResponse(error),
30+
})
31+
if (!parsed.success) return parsed.response
32+
33+
const { tableId } = parsed.data.params
34+
const { workspaceId } = parsed.data.query
35+
36+
const result = await checkAccess(tableId, authResult.userId, 'read')
37+
if (!result.ok) return accessError(result, requestId, tableId)
38+
39+
if (result.table.workspaceId !== workspaceId) {
40+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
41+
}
42+
43+
const columns = (result.table.schema as TableSchema).columns ?? []
44+
const views = await listTableViews(tableId, columns)
45+
46+
return NextResponse.json({ success: true, data: { views } })
47+
} catch (error) {
48+
logger.error(`[${requestId}] Error listing table views:`, error)
49+
return NextResponse.json({ error: 'Failed to list views' }, { status: 500 })
50+
}
51+
})
52+
53+
/** POST /api/table/[tableId]/views - Save the current filter/sort/layout as a named view. */
54+
export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
55+
const requestId = generateRequestId()
56+
57+
try {
58+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
59+
if (!authResult.success || !authResult.userId) {
60+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
61+
}
62+
63+
const parsed = await parseRequest(createTableViewContract, request, context, {
64+
validationErrorResponse: (error) => validationErrorResponse(error),
65+
})
66+
if (!parsed.success) return parsed.response
67+
68+
const { tableId } = parsed.data.params
69+
const { workspaceId, name, config } = parsed.data.body
70+
71+
const result = await checkAccess(tableId, authResult.userId, 'write')
72+
if (!result.ok) return accessError(result, requestId, tableId)
73+
74+
if (result.table.workspaceId !== workspaceId) {
75+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
76+
}
77+
78+
const columns = (result.table.schema as TableSchema).columns ?? []
79+
const view = await createTableView({
80+
tableId,
81+
workspaceId,
82+
name,
83+
config,
84+
userId: authResult.userId,
85+
columns,
86+
})
87+
88+
return NextResponse.json({ success: true, data: { view } })
89+
} catch (error) {
90+
if (error instanceof TableViewValidationError) {
91+
return NextResponse.json({ error: error.message }, { status: 400 })
92+
}
93+
logger.error(`[${requestId}] Error creating table view:`, error)
94+
return NextResponse.json({ error: 'Failed to create view' }, { status: 500 })
95+
}
96+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
dbChainMockFns,
7+
flattenMockConditions,
8+
queueTableRows,
9+
resetDbChainMock,
10+
schemaMock,
11+
} from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
/**
15+
* The route composes `withAdminAuthParams`, so auth is bypassed by making that wrapper a
16+
* passthrough — the assertions here are about query construction, not the auth gate.
17+
*/
18+
vi.mock('@/app/api/v1/admin/middleware', () => ({
19+
withAdminAuthParams: (handler: unknown) => handler,
20+
}))
21+
22+
import { GET } from '@/app/api/v1/admin/workspaces/[id]/folders/route'
23+
24+
const WORKSPACE_ID = 'ws-1'
25+
const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
26+
27+
function listRequest() {
28+
return createMockRequest(
29+
'GET',
30+
undefined,
31+
{},
32+
`http://localhost:3000/api/v1/admin/workspaces/${WORKSPACE_ID}/folders?limit=50&offset=0`
33+
)
34+
}
35+
36+
describe('admin workspace folders GET', () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
resetDbChainMock()
40+
})
41+
42+
it('excludes soft-deleted folders from both the count and the page', async () => {
43+
queueTableRows(schemaMock.workspace, [{ id: WORKSPACE_ID }])
44+
queueTableRows(schemaMock.folder, [{ total: 0 }])
45+
queueTableRows(schemaMock.folder, [])
46+
47+
await GET(listRequest(), routeContext)
48+
49+
// Calls: [0] workspace lookup, then the count and page share one prebuilt condition.
50+
const folderWheres = dbChainMockFns.where.mock.calls.slice(1).map(([where]) => where)
51+
expect(folderWheres.length).toBeGreaterThanOrEqual(2)
52+
for (const where of folderWheres) {
53+
// Pinned to the column so the assertion stays meaningful if another nullable filter
54+
// (e.g. a parent scope) is ever added to this condition.
55+
expect(
56+
flattenMockConditions(where).some(
57+
(node) => node.type === 'isNull' && node.column === schemaMock.folder.deletedAt
58+
)
59+
).toBe(true)
60+
}
61+
})
62+
63+
it('still scopes to the workspace and to workflow folders', async () => {
64+
queueTableRows(schemaMock.workspace, [{ id: WORKSPACE_ID }])
65+
queueTableRows(schemaMock.folder, [{ total: 0 }])
66+
queueTableRows(schemaMock.folder, [])
67+
68+
await GET(listRequest(), routeContext)
69+
70+
const where = dbChainMockFns.where.mock.calls[1]?.[0]
71+
const nodes = flattenMockConditions(where)
72+
expect(nodes.some((n) => n.type === 'eq' && n.right === WORKSPACE_ID)).toBe(true)
73+
expect(nodes.some((n) => n.type === 'eq' && n.right === 'workflow')).toBe(true)
74+
})
75+
})

apps/sim/app/api/v1/admin/workspaces/[id]/folders/route.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { db } from '@sim/db'
1414
import { folder as folderTable, workspace } from '@sim/db/schema'
1515
import { createLogger } from '@sim/logger'
16-
import { and, count, eq } from 'drizzle-orm'
16+
import { and, count, eq, isNull } from 'drizzle-orm'
1717
import { adminV1ListWorkspaceFoldersContract } from '@/lib/api/contracts/v1/admin'
1818
import { parseRequest } from '@/lib/api/server'
1919
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -46,19 +46,23 @@ export const GET = withRouteHandler(
4646
return notFoundResponse('Workspace')
4747
}
4848

49+
/**
50+
* Without the `deletedAt` filter the count and the page both include rows sitting in
51+
* Recently Deleted, so an operator sees phantom folders and an inflated total, and this
52+
* endpoint disagrees with every user-facing folder list.
53+
*/
54+
const activeWorkflowFolders = and(
55+
eq(folderTable.workspaceId, workspaceId),
56+
eq(folderTable.resourceType, 'workflow'),
57+
isNull(folderTable.deletedAt)
58+
)
59+
4960
const [countResult, folders] = await Promise.all([
50-
db
51-
.select({ total: count() })
52-
.from(folderTable)
53-
.where(
54-
and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow'))
55-
),
61+
db.select({ total: count() }).from(folderTable).where(activeWorkflowFolders),
5662
db
5763
.select()
5864
.from(folderTable)
59-
.where(
60-
and(eq(folderTable.workspaceId, workspaceId), eq(folderTable.resourceType, 'workflow'))
61-
)
65+
.where(activeWorkflowFolders)
6266
.orderBy(folderTable.sortOrder, folderTable.name)
6367
.limit(limit)
6468
.offset(offset),

apps/sim/app/workspace/[workspaceId]/chat/[chatId]/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Metadata } from 'next'
33
import { getSession } from '@/lib/auth'
44
import { Home } from '@/app/workspace/[workspaceId]/home/home'
55
import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback'
6+
import { resolveTableViewsEnabled } from '@/app/workspace/[workspaceId]/home/resolve-table-views-flag'
67

78
export const metadata: Metadata = {
89
title: 'Chat',
@@ -16,14 +17,17 @@ interface ChatPageProps {
1617
}
1718

1819
export default async function ChatPage({ params }: ChatPageProps) {
19-
const [{ chatId }, session] = await Promise.all([params, getSession()])
20+
const [{ workspaceId, chatId }, session] = await Promise.all([params, getSession()])
21+
const userId = session?.user?.id
22+
const tableViewsEnabled = await resolveTableViewsEnabled(workspaceId, userId)
2023
return (
2124
<Suspense fallback={<HomeFallback />}>
2225
<Home
2326
key={chatId}
2427
chatId={chatId}
2528
userName={session?.user?.name}
26-
userId={session?.user?.id}
29+
userId={userId}
30+
tableViewsEnabled={tableViewsEnabled}
2731
/>
2832
</Suspense>
2933
)

0 commit comments

Comments
 (0)