Skip to content

Commit 2ebfdaf

Browse files
committed
fix(admin): close dashboard recovery gaps
1 parent 27981b8 commit 2ebfdaf

23 files changed

Lines changed: 1357 additions & 200 deletions

apps/sim/app/api/v1/admin/dashboard/organizations/[id]/members/preflight/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ export const GET = withRouteHandler(
1818
if (!parsed.success) return parsed.response
1919
try {
2020
return singleResponse(
21-
await getDashboardMemberTransferPreflight(parsed.data.params.id, parsed.data.query.userId)
21+
await getDashboardMemberTransferPreflight(parsed.data.params.id, parsed.data.query.userId, {
22+
search: parsed.data.query.search,
23+
limit: parsed.data.query.limit,
24+
offset: parsed.data.query.offset,
25+
})
2226
)
2327
} catch (error) {
2428
return badRequestResponse(getErrorMessage(error, 'Failed to prepare member transfer'))

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const POST = withRouteHandler(
3838
auditActor: actor,
3939
auditOperationId: parsed.data.body.operationId,
4040
operationCorrelationId: parsed.data.body.operationId,
41+
durableOperationId: parsed.data.body.operationId,
4142
})
4243
const data = await toWorkspaceMoveOperationView(summary, parsed.data.body.operationId)
4344
return NextResponse.json({ data })

apps/sim/lib/admin/dashboard-organizations.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
permissions,
77
subscription,
88
usageLog,
9+
user,
910
workspace,
1011
} from '@sim/db/schema'
1112
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
@@ -17,6 +18,7 @@ const mocks = vi.hoisted(() => ({
1718
provisionings: new Map(),
1819
resolveMetadataIntent: vi.fn(),
1920
enqueueOutboxEvent: vi.fn(),
21+
countPendingSeatInvitations: vi.fn(),
2022
}))
2123

2224
vi.mock('@sim/audit', () => ({
@@ -64,19 +66,107 @@ vi.mock('@/lib/billing/organizations/membership', () => ({
6466
transferOrganizationOwnership: vi.fn(),
6567
}))
6668
vi.mock('@/lib/billing/organizations/seats', () => ({ reconcileOrganizationSeats: vi.fn() }))
69+
vi.mock('@/lib/billing/validation/seat-management', () => ({
70+
countPendingSeatInvitations: mocks.countPendingSeatInvitations,
71+
}))
6772
vi.mock('@/lib/core/idempotency/transaction', () => ({
6873
executeTransactionallyIdempotent: vi.fn(),
6974
}))
7075
vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: mocks.enqueueOutboxEvent }))
7176

7277
import {
78+
getDashboardMemberTransferPreflight,
7379
getDashboardOrganization,
7480
listDashboardOrganizations,
7581
toDashboardConfigurationUpdate,
7682
updateDashboardEnterpriseBillingTerms,
83+
updateDashboardEnterpriseSeats,
7784
updateDashboardOrganizationLimits,
7885
} from '@/lib/admin/dashboard'
7986

87+
describe('getDashboardMemberTransferPreflight', () => {
88+
beforeEach(() => {
89+
vi.clearAllMocks()
90+
resetDbChainMock()
91+
})
92+
93+
it('pages workspace choices and exposes an empty default selection above the exact cap', async () => {
94+
queueTableRows(organization, [{ id: 'org-destination' }])
95+
queueTableRows(user, [
96+
{
97+
id: 'user-1',
98+
name: 'User',
99+
email: 'user@example.com',
100+
memberId: null,
101+
role: null,
102+
organizationId: null,
103+
organizationName: null,
104+
},
105+
])
106+
queueTableRows(workspace, [{ value: 75 }])
107+
queueTableRows(workspace, [
108+
{ id: 'workspace-51', name: 'Matching workspace', archivedAt: null },
109+
])
110+
queueTableRows(workspace, [
111+
{
112+
id: 'workspace-1',
113+
name: 'First eligible workspace',
114+
archivedAt: null,
115+
total: 1_205,
116+
},
117+
])
118+
119+
const result = await getDashboardMemberTransferPreflight('org-destination', 'user-1', {
120+
search: 'matching',
121+
limit: 25,
122+
offset: 50,
123+
})
124+
125+
expect(result.personalWorkspaces).toEqual([
126+
{ id: 'workspace-51', name: 'Matching workspace', archived: false },
127+
])
128+
expect(result.workspacePagination).toEqual({
129+
total: 75,
130+
limit: 25,
131+
offset: 50,
132+
hasMore: true,
133+
})
134+
expect(result.workspaceSelection).toEqual({
135+
totalEligible: 1_205,
136+
defaultSelectedIds: [],
137+
defaultSelectedWorkspaces: [],
138+
includesAllEligible: false,
139+
limit: 1_000,
140+
})
141+
expect(dbChainMockFns.limit).toHaveBeenCalledWith(1_001)
142+
})
143+
})
144+
145+
describe('updateDashboardEnterpriseSeats', () => {
146+
beforeEach(() => {
147+
vi.clearAllMocks()
148+
resetDbChainMock()
149+
})
150+
151+
it('refuses to reduce capacity below members plus live pending seat reservations', async () => {
152+
queueTableRows(subscription, [
153+
{ id: 'sub-1', plan: 'enterprise', status: 'active', metadata: { seats: 10 } },
154+
])
155+
queueTableRows(member, [{ value: 5 }])
156+
mocks.countPendingSeatInvitations.mockResolvedValue(2)
157+
158+
await expect(
159+
updateDashboardEnterpriseSeats('org-1', 6, {
160+
id: 'admin-1',
161+
name: 'Admin',
162+
email: 'admin@example.com',
163+
})
164+
).rejects.toThrow('below 7 occupied or reserved seats')
165+
166+
expect(mocks.enqueueOutboxEvent).not.toHaveBeenCalled()
167+
})
168+
})
169+
80170
afterAll(() => {
81171
resetDbChainMock()
82172
})

apps/sim/lib/admin/dashboard.ts

Lines changed: 92 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
isOrgScopedSubscription,
7373
} from '@/lib/billing/subscriptions/utils'
7474
import { toDecimal } from '@/lib/billing/utils/decimal'
75+
import { countPendingSeatInvitations } from '@/lib/billing/validation/seat-management'
7576
import { env } from '@/lib/core/config/env'
7677
import { executeTransactionallyIdempotent } from '@/lib/core/idempotency/transaction'
7778
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
@@ -84,6 +85,8 @@ interface PaginationInput {
8485
offset: number
8586
}
8687

88+
const MAX_ADMIN_MEMBER_WORKSPACE_SELECTION = 1_000
89+
8790
export interface AdminMutationActor {
8891
id: string | null
8992
name: string
@@ -1119,8 +1122,12 @@ export async function updateDashboardEnterpriseSeats(
11191122
.select({ value: count() })
11201123
.from(member)
11211124
.where(eq(member.organizationId, organizationId))
1122-
if (seats < (memberCountRow?.value ?? 0)) {
1123-
throw new Error('Seat capacity cannot be below current internal membership')
1125+
const pendingSeats = await countPendingSeatInvitations(organizationId, tx)
1126+
const requiredSeats = (memberCountRow?.value ?? 0) + pendingSeats
1127+
if (seats < requiredSeats) {
1128+
throw new Error(
1129+
`Seat capacity cannot be below ${requiredSeats} occupied or reserved seats (${memberCountRow?.value ?? 0} members and ${pendingSeats} pending invitations)`
1130+
)
11241131
}
11251132
await enqueueEnterpriseMetadataIntent(tx, {
11261133
subscriptionId: subscriptionRow.id,
@@ -1135,7 +1142,7 @@ export async function updateDashboardEnterpriseSeats(
11351142
action: AuditAction.ORG_SEAT_PROVISIONED,
11361143
resourceType: AuditResourceType.ORGANIZATION,
11371144
resourceId: organizationId,
1138-
description: `Admin set Enterprise seat capacity to ${seats}`,
1145+
description: `Admin requested Enterprise seat capacity ${seats}`,
11391146
metadata: { seats },
11401147
})
11411148
}
@@ -1346,7 +1353,7 @@ export async function updateDashboardOrganizationLimits(
13461353
},
13471354
actor: AdminMutationActor
13481355
) {
1349-
await db.transaction(async (tx) => {
1356+
const providerBacked = await db.transaction(async (tx) => {
13501357
await acquireOrganizationMutationLock(tx, organizationId)
13511358
const [org] = await tx
13521359
.select()
@@ -1413,7 +1420,7 @@ export async function updateDashboardOrganizationLimits(
14131420
}
14141421
},
14151422
})
1416-
return
1423+
return true
14171424
}
14181425

14191426
const [memberCountRow] = await tx
@@ -1447,6 +1454,7 @@ export async function updateDashboardOrganizationLimits(
14471454
})
14481455
.where(eq(subscription.id, subscriptionRow.id))
14491456
}
1457+
return false
14501458
})
14511459
recordAudit({
14521460
actorId: actor.id,
@@ -1455,7 +1463,9 @@ export async function updateDashboardOrganizationLimits(
14551463
action: AuditAction.ORGANIZATION_UPDATED,
14561464
resourceType: AuditResourceType.ORGANIZATION,
14571465
resourceId: organizationId,
1458-
description: 'Admin updated organization limits',
1466+
description: providerBacked
1467+
? 'Admin requested Enterprise organization-limit update'
1468+
: 'Admin updated organization limits',
14591469
metadata: values,
14601470
})
14611471
}
@@ -1681,35 +1691,62 @@ export async function grantDashboardUserBalance(
16811691

16821692
export async function getDashboardMemberTransferPreflight(
16831693
destinationOrganizationId: string,
1684-
userId: string
1694+
userId: string,
1695+
workspacePage: PaginationInput = { search: '', limit: 50, offset: 0 }
16851696
) {
1686-
const [[destination], [target], personalWorkspaces] = await Promise.all([
1687-
db
1688-
.select({ id: organization.id })
1689-
.from(organization)
1690-
.where(eq(organization.id, destinationOrganizationId))
1691-
.limit(1),
1692-
db
1693-
.select({
1694-
id: user.id,
1695-
name: user.name,
1696-
email: user.email,
1697-
memberId: member.id,
1698-
role: member.role,
1699-
organizationId: member.organizationId,
1700-
organizationName: organization.name,
1701-
})
1702-
.from(user)
1703-
.leftJoin(member, eq(member.userId, user.id))
1704-
.leftJoin(organization, eq(organization.id, member.organizationId))
1705-
.where(eq(user.id, userId))
1706-
.limit(1),
1707-
db
1708-
.select({ id: workspace.id, name: workspace.name, archivedAt: workspace.archivedAt })
1709-
.from(workspace)
1710-
.where(ownedAttachableWorkspacesWhere({ userId, includeArchived: true }))
1711-
.orderBy(workspace.name, workspace.id),
1712-
])
1697+
const search = workspacePage.search.trim()
1698+
const limit = Math.min(Math.max(workspacePage.limit, 1), 250)
1699+
const offset = Math.max(workspacePage.offset, 0)
1700+
const allPersonalWorkspacesWhere = ownedAttachableWorkspacesWhere({
1701+
userId,
1702+
includeArchived: true,
1703+
})
1704+
const matchingPersonalWorkspacesWhere = and(
1705+
allPersonalWorkspacesWhere,
1706+
search ? or(eq(workspace.id, search), ilike(workspace.name, `%${search}%`)) : undefined
1707+
)
1708+
const [[destination], [target], personalWorkspaceCount, personalWorkspaces, selectionRows] =
1709+
await Promise.all([
1710+
db
1711+
.select({ id: organization.id })
1712+
.from(organization)
1713+
.where(eq(organization.id, destinationOrganizationId))
1714+
.limit(1),
1715+
db
1716+
.select({
1717+
id: user.id,
1718+
name: user.name,
1719+
email: user.email,
1720+
memberId: member.id,
1721+
role: member.role,
1722+
organizationId: member.organizationId,
1723+
organizationName: organization.name,
1724+
})
1725+
.from(user)
1726+
.leftJoin(member, eq(member.userId, user.id))
1727+
.leftJoin(organization, eq(organization.id, member.organizationId))
1728+
.where(eq(user.id, userId))
1729+
.limit(1),
1730+
db.select({ value: count() }).from(workspace).where(matchingPersonalWorkspacesWhere),
1731+
db
1732+
.select({ id: workspace.id, name: workspace.name, archivedAt: workspace.archivedAt })
1733+
.from(workspace)
1734+
.where(matchingPersonalWorkspacesWhere)
1735+
.orderBy(workspace.name, workspace.id)
1736+
.limit(limit)
1737+
.offset(offset),
1738+
db
1739+
.select({
1740+
id: workspace.id,
1741+
name: workspace.name,
1742+
archivedAt: workspace.archivedAt,
1743+
total: sql<number>`count(*) over()`.mapWith(Number),
1744+
})
1745+
.from(workspace)
1746+
.where(allPersonalWorkspacesWhere)
1747+
.orderBy(workspace.id)
1748+
.limit(MAX_ADMIN_MEMBER_WORKSPACE_SELECTION + 1),
1749+
])
17131750
if (!destination) throw new Error('Destination organization not found')
17141751
if (!target) throw new Error('User not found')
17151752

@@ -1724,6 +1761,9 @@ export async function getDashboardMemberTransferPreflight(
17241761
: credentialDependencies.length > 0
17251762
? 'Reconnect or remove source-organization credentials owned by this user before transfer'
17261763
: null
1764+
const matchingWorkspaceTotal = personalWorkspaceCount[0]?.value ?? 0
1765+
const totalEligibleWorkspaces = selectionRows[0]?.total ?? 0
1766+
const includesAllEligible = totalEligibleWorkspaces <= MAX_ADMIN_MEMBER_WORKSPACE_SELECTION
17271767

17281768
return {
17291769
user: { id: target.id, name: target.name, email: target.email },
@@ -1736,6 +1776,24 @@ export async function getDashboardMemberTransferPreflight(
17361776
name: row.name,
17371777
archived: row.archivedAt !== null,
17381778
})),
1779+
workspacePagination: {
1780+
total: matchingWorkspaceTotal,
1781+
limit,
1782+
offset,
1783+
hasMore: offset + personalWorkspaces.length < matchingWorkspaceTotal,
1784+
},
1785+
workspaceSelection: {
1786+
totalEligible: totalEligibleWorkspaces,
1787+
defaultSelectedIds: includesAllEligible ? selectionRows.map((row) => row.id) : [],
1788+
defaultSelectedWorkspaces: includesAllEligible
1789+
? selectionRows.map(({ total: _total, archivedAt, ...row }) => ({
1790+
...row,
1791+
archived: archivedAt !== null,
1792+
}))
1793+
: [],
1794+
includesAllEligible,
1795+
limit: MAX_ADMIN_MEMBER_WORKSPACE_SELECTION,
1796+
},
17391797
credentialDependencies,
17401798
canAdd: reason === null,
17411799
reason,

apps/sim/lib/admin/invitation-operation.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,7 @@ describe('Admin invitation operation', () => {
9494
expect.objectContaining({ email: 'a@example.com', source: 'admin', sequence: 0 }),
9595
expect.objectContaining({ email: 'b@example.com', source: 'admin', sequence: 1 }),
9696
])
97-
expect(mocks.recordAuditOnce).toHaveBeenCalledWith(
98-
'11111111-1111-4111-8111-111111111111:requested',
99-
expect.objectContaining({ resourceId: 'org-1' })
100-
)
97+
expect(mocks.recordAuditOnce).not.toHaveBeenCalled()
10198
})
10299

103100
it('waits for every recipient before the parent operation completes', async () => {
@@ -130,5 +127,9 @@ describe('Admin invitation operation', () => {
130127
reason: 'Waiting for invitation recipients',
131128
consumeAttempt: false,
132129
})
130+
expect(mocks.recordAuditOnce).toHaveBeenCalledWith(
131+
'11111111-1111-4111-8111-111111111111:requested',
132+
expect.objectContaining({ resourceId: 'org-1' })
133+
)
133134
})
134135
})

0 commit comments

Comments
 (0)