Skip to content

Commit 1552420

Browse files
committed
Plugin: getUserRoles(userIds, orgId) batch lookup
Adds a batch variant of getUserRole that returns a Map<userId, Role | null> in one round-trip. Used by TeamPresenter to drop the N+1 it had been documenting as a future optimisation. Org-scoped only — project-scoped reads still go through getUserRole (less common, not worth complicating the API for).
1 parent e722f6c commit 1552420

4 files changed

Lines changed: 30 additions & 13 deletions

File tree

apps/webapp/app/presenters/TeamPresenter.server.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class TeamPresenter extends BasePresenter {
1414
return;
1515
}
1616

17-
const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoles] =
17+
const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] =
1818
await Promise.all([
1919
getLimit(organizationId, "teamMembers", 100_000_000),
2020
getCurrentPlan(organizationId),
@@ -27,21 +27,18 @@ export class TeamPresenter extends BasePresenter {
2727
// in this set. Server-side enforcement is independent (setUserRole
2828
// rejects a plan-gated assignment regardless of UI state).
2929
rbac.getAssignableRoleIds(organizationId),
30-
// Per-member current role. N+1 by design: this page is rendered
31-
// for admins on a low-traffic settings screen, and the rbac plugin
32-
// doesn't currently expose a batched lookup. Switching to a single
33-
// Drizzle query keyed on (orgId, userIds[]) is a future optimisation.
34-
Promise.all(
35-
result.members.map(async (m) => ({
36-
userId: m.user.id,
37-
role: await rbac.getUserRole({
38-
userId: m.user.id,
39-
organizationId,
40-
}),
41-
}))
30+
// Per-member current role in a single round-trip.
31+
rbac.getUserRoles(
32+
result.members.map((m) => m.user.id),
33+
organizationId
4234
),
4335
]);
4436

37+
const memberRoles = result.members.map((m) => ({
38+
userId: m.user.id,
39+
role: memberRoleMap.get(m.user.id) ?? null,
40+
}));
41+
4542
const canPurchaseSeats =
4643
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
4744
const extraSeats = currentPlan?.v3Subscription?.addOns?.seats?.purchased ?? 0;

internal-packages/rbac/src/fallback.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
195195
return null;
196196
}
197197

198+
async getUserRoles(userIds: string[]): Promise<Map<string, Role | null>> {
199+
return new Map(userIds.map((id) => [id, null]));
200+
}
201+
198202
async setUserRole(): Promise<RoleAssignmentResult> {
199203
return { ok: false, error: "RBAC plugin not installed" };
200204
}

internal-packages/rbac/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ class LazyController implements RoleBaseAccessController {
193193
return (await this.c()).getUserRole(...args);
194194
}
195195

196+
async getUserRoles(
197+
...args: Parameters<RoleBaseAccessController["getUserRoles"]>
198+
): Promise<Map<string, Role | null>> {
199+
return (await this.c()).getUserRoles(...args);
200+
}
201+
196202
async setUserRole(
197203
...args: Parameters<RoleBaseAccessController["setUserRole"]>
198204
): Promise<RoleAssignmentResult> {

packages/plugins/src/rbac.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,16 @@ export interface RoleBaseAccessController {
182182
projectId?: string;
183183
}): Promise<Role | null>;
184184

185+
// Batch variant for callers that need per-user roles for many users
186+
// in one round-trip (e.g. the Team page rendering N members).
187+
// Org-scoped only — project-scoped reads still go through getUserRole.
188+
// Returns a Map keyed by userId; users with no resolvable role map to
189+
// null. The default fallback returns a Map of all userIds → null.
190+
getUserRoles(
191+
userIds: string[],
192+
organizationId: string
193+
): Promise<Map<string, Role | null>>;
194+
185195
setUserRole(params: {
186196
userId: string;
187197
organizationId: string;

0 commit comments

Comments
 (0)