Add lightweight user directory endpoint and migrate UI to use it

Introduces GET /companies/:companyId/user-directory returning only active
human members with resolved name/email/image. Switches all picker and
display components (ExecutionParticipantPicker, IssueProperties,
NewIssueDialog, NewProjectDialog, Inbox) from the heavier listMembers
endpoint to the new read-only directory. Broadens company-members helper
types to accept both full member records and directory entries.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-13 15:26:48 -05:00
1 parent 5ce75c3adf
commit fe6efaad03
13 files changed
+243 -38

No files matched your search

@@ -0,0 +1,132 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
vi.mock("../services/index.js", () => ({
accessService: () => ({
isInstanceAdmin: vi.fn(),
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
boardAuthService: () => ({
createChallenge: vi.fn(),
resolveBoardAccess: vi.fn(),
assertCurrentBoardKey: vi.fn(),
revokeBoardApiKey: vi.fn(),
}),
deduplicateAgentName: vi.fn(),
logActivity: vi.fn(),
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const activeMemberships = [
{ principalId: "user-2", status: "active" as const },
{ principalId: "user-1", status: "active" as const },
];
const users = [
{ id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" },
{ id: "user-2", name: null, email: "alex@example.com", image: null },
];
const isCompanyMembershipsTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"membershipRole" in table &&
"principalType" in table &&
"principalId" in table;
const isAuthUsersTable = (table: unknown) =>
!!table &&
typeof table === "object" &&
"emailVerified" in table &&
"createdAt" in table &&
"updatedAt" in table;
return {
select() {
return {
from(table: unknown) {
if (isCompanyMembershipsTable(table)) {
const query = {
where() {
return query;
},
orderBy() {
return Promise.resolve(activeMemberships);
},
};
return query;
}
if (isAuthUsersTable(table)) {
return {
where() {
return Promise.resolve(users);
},
};
}
throw new Error("Unexpected table");
},
};
},
};
}
function createApp(actor: Express.Request["actor"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use(
"/api",
accessRoutes(createDbStub() as never, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("GET /companies/:companyId/user-directory", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns active human users for operators without manage-permissions access", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "session",
isInstanceAdmin: false,
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }],
});
const res = await request(app).get("/api/companies/company-1/user-directory");
expect(res.status).toBe(200);
expect(res.body).toEqual({
users: [
{
principalId: "user-2",
status: "active",
user: { id: "user-2", name: null, email: "alex@example.com", image: null },
},
{
principalId: "user-1",
status: "active",
user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" },
},
],
});
});
});
+33
View File
@@ -1033,6 +1033,32 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) {
}));
}
async function loadCompanyUserDirectory(db: Db, companyId: string) {
const members = await db
.select({
principalId: companyMemberships.principalId,
status: companyMemberships.status,
})
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
),
)
.orderBy(desc(companyMemberships.updatedAt));
const userIds = [...new Set(members.map((member) => member.principalId))];
const userMap = await loadUsersById(db, userIds);
return members.map((member) => ({
principalId: member.principalId,
status: "active" as const,
user: userMap.get(member.principalId) ?? null,
}));
}
function inviteStateWhereClause(
state: "active" | "accepted" | "expired" | "revoked" | undefined,
) {
@@ -3531,6 +3557,13 @@ export function accessRoutes(
});
});
router.get("/companies/:companyId/user-directory", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const users = await loadCompanyUserDirectory(db, companyId);
res.json({ users });
});
router.patch(
"/companies/:companyId/members/:memberId",
validate(updateCompanyMemberSchema),
+13
View File
@@ -138,6 +138,16 @@ export type CompanyMembersResponse = {
};
};
export type CompanyUserDirectoryEntry = {
principalId: string;
status: "active";
user: { id: string; email: string | null; name: string | null; image: string | null } | null;
};
export type CompanyUserDirectoryResponse = {
users: CompanyUserDirectoryEntry[];
};
export type CompanyInviteRecord = {
id: string;
companyId: string | null;
@@ -295,6 +305,9 @@ export const accessApi = {
listMembers: (companyId: string) =>
api.get<CompanyMembersResponse>(`/companies/${companyId}/members`),
listUserDirectory: (companyId: string) =>
api.get<CompanyUserDirectoryResponse>(`/companies/${companyId}/user-directory`),
updateMember: (
companyId: string,
memberId: string,
@@ -39,8 +39,8 @@ export function ExecutionParticipantPicker({
const approverValues = stageParticipantValues(issue.executionPolicy, "approval");
const values = stageType === "review" ? reviewerValues : approverValues;
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyMembers(issue.companyId),
queryFn: () => accessApi.listMembers(issue.companyId),
queryKey: queryKeys.access.companyUserDirectory(issue.companyId),
queryFn: () => accessApi.listUserDirectory(issue.companyId),
enabled: !!issue.companyId,
});
@@ -49,12 +49,12 @@ export function ExecutionParticipantPicker({
getRecentAssigneeIds(),
);
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.members),
[companyMembers?.members],
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const otherUserOptions = useMemo(
() => buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.members, currentUserId, issue.createdByUserId],
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.users, currentUserId, issue.createdByUserId],
);
const userLabel = (userId: string | null | undefined) =>
+6 -6
View File
@@ -192,8 +192,8 @@ export function IssueProperties({
enabled: !!companyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyMembers(companyId!),
queryFn: () => accessApi.listMembers(companyId!),
queryKey: queryKeys.access.companyUserDirectory(companyId!),
queryFn: () => accessApi.listUserDirectory(companyId!),
enabled: !!companyId,
});
@@ -267,12 +267,12 @@ export function IssueProperties({
[agents, recentAssigneeIds],
);
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.members),
[companyMembers?.members],
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const otherUserOptions = useMemo(
() => buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.members, currentUserId, issue.createdByUserId],
() => buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId, issue.createdByUserId] }),
[companyMembers?.users, currentUserId, issue.createdByUserId],
);
const assignee = issue.assigneeAgentId
+6 -6
View File
@@ -356,8 +356,8 @@ export function NewIssueDialog() {
queryFn: () => authApi.getSession(),
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyMembers(effectiveCompanyId!),
queryFn: () => accessApi.listMembers(effectiveCompanyId!),
queryKey: queryKeys.access.companyUserDirectory(effectiveCompanyId!),
queryFn: () => accessApi.listUserDirectory(effectiveCompanyId!),
enabled: Boolean(effectiveCompanyId) && newIssueOpen,
});
const { data: experimentalSettings } = useQuery({
@@ -389,9 +389,9 @@ export function NewIssueDialog() {
return buildMarkdownMentionOptions({
agents,
projects: orderedProjects,
members: companyMembers?.members,
members: companyMembers?.users,
});
}, [agents, companyMembers?.members, orderedProjects]);
}, [agents, companyMembers?.users, orderedProjects]);
const { data: assigneeAdapterModels } = useQuery({
queryKey:
@@ -857,7 +857,7 @@ export function NewIssueDialog() {
const assigneeOptions = useMemo<InlineEntityOption[]>(
() => [
...currentUserAssigneeOption(currentUserId),
...buildCompanyUserInlineOptions(companyMembers?.members, { excludeUserIds: [currentUserId] }),
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
...sortAgentsByRecency(
(agents ?? []).filter((agent) => agent.status !== "terminated"),
recentAssigneeIds,
@@ -867,7 +867,7 @@ export function NewIssueDialog() {
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
],
[agents, companyMembers?.members, currentUserId, recentAssigneeIds],
[agents, companyMembers?.users, currentUserId, recentAssigneeIds],
);
const projectOptions = useMemo<InlineEntityOption[]>(
() =>
+4 -4
View File
@@ -78,17 +78,17 @@ export function NewProjectDialog() {
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyMembers(selectedCompanyId!),
queryFn: () => accessApi.listMembers(selectedCompanyId!),
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
members: companyMembers?.members,
members: companyMembers?.users,
});
}, [agents, companyMembers?.members]);
}, [agents, companyMembers?.users]);
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { CompanyMember } from "@/api/access";
import type { CompanyMember, CompanyUserDirectoryEntry } from "@/api/access";
import {
buildCompanyUserInlineOptions,
buildCompanyUserLabelMap,
@@ -81,4 +81,25 @@ describe("company-members helpers", () => {
{ id: "project:project-1", name: "Paperclip App", kind: "project", projectId: "project-1", projectColor: "#336699" },
]);
});
it("accepts read-only directory entries for assignee and mention helpers", () => {
const users: CompanyUserDirectoryEntry[] = [
{
principalId: "user-1",
status: "active",
user: { id: "user-1", name: "Taylor", email: "taylor@example.com", image: null },
},
];
expect(buildCompanyUserInlineOptions(users)).toEqual([
{
id: "user:user-1",
label: "Taylor",
searchText: "Taylor taylor@example.com user-1",
},
]);
expect(buildMarkdownMentionOptions({ members: users })).toEqual([
{ id: "user:user-1", name: "Taylor", kind: "user", userId: "user-1" },
]);
});
});
+12 -9
View File
@@ -1,4 +1,4 @@
import type { CompanyMember } from "@/api/access";
import type { CompanyMember, CompanyUserDirectoryEntry } from "@/api/access";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import type { MentionOption } from "@/components/MarkdownEditor";
import type { Agent, Project } from "@paperclipai/shared";
@@ -8,12 +8,15 @@ export interface CompanyUserProfile {
image: string | null;
}
type CompanyUserRecord = Pick<CompanyMember, "principalId" | "status" | "user">
| CompanyUserDirectoryEntry;
function fallbackUserLabel(userId: string): string {
if (userId === "local-board") return "Board";
return userId.slice(0, 5);
}
function baseMemberLabel(member: Pick<CompanyMember, "principalId" | "user">): string {
function baseMemberLabel(member: Pick<CompanyUserRecord, "principalId" | "user">): string {
const name = member.user?.name?.trim();
if (name) return name;
const email = member.user?.email?.trim();
@@ -21,8 +24,8 @@ function baseMemberLabel(member: Pick<CompanyMember, "principalId" | "user">): s
return fallbackUserLabel(member.principalId);
}
function activeUniqueMembers(members: CompanyMember[] | null | undefined) {
const byId = new Map<string, CompanyMember>();
function activeUniqueMembers(members: CompanyUserRecord[] | null | undefined) {
const byId = new Map<string, CompanyUserRecord>();
for (const member of members ?? []) {
if (member.status !== "active") continue;
if (!byId.has(member.principalId)) {
@@ -32,7 +35,7 @@ function activeUniqueMembers(members: CompanyMember[] | null | undefined) {
return [...byId.values()].sort((left, right) => baseMemberLabel(left).localeCompare(baseMemberLabel(right)));
}
export function buildCompanyUserLabelMap(members: CompanyMember[] | null | undefined): Map<string, string> {
export function buildCompanyUserLabelMap(members: CompanyUserRecord[] | null | undefined): Map<string, string> {
const labels = new Map<string, string>();
for (const member of members ?? []) {
labels.set(member.principalId, baseMemberLabel(member));
@@ -41,7 +44,7 @@ export function buildCompanyUserLabelMap(members: CompanyMember[] | null | undef
}
export function buildCompanyUserProfileMap(
members: CompanyMember[] | null | undefined,
members: CompanyUserRecord[] | null | undefined,
): Map<string, CompanyUserProfile> {
const profiles = new Map<string, CompanyUserProfile>();
for (const member of members ?? []) {
@@ -54,7 +57,7 @@ export function buildCompanyUserProfileMap(
}
export function buildCompanyUserInlineOptions(
members: CompanyMember[] | null | undefined,
members: CompanyUserRecord[] | null | undefined,
options?: { excludeUserIds?: Iterable<string | null | undefined> },
): InlineEntityOption[] {
const exclude = new Set(
@@ -71,7 +74,7 @@ export function buildCompanyUserInlineOptions(
}
export function buildCompanyUserMentionOptions(
members: CompanyMember[] | null | undefined,
members: CompanyUserRecord[] | null | undefined,
): MentionOption[] {
return activeUniqueMembers(members).map((member) => ({
id: `user:${member.principalId}`,
@@ -84,7 +87,7 @@ export function buildCompanyUserMentionOptions(
export function buildMarkdownMentionOptions(args: {
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon">> | null | undefined;
projects?: Array<Pick<Project, "id" | "name" | "color">> | null | undefined;
members?: CompanyMember[] | null | undefined;
members?: CompanyUserRecord[] | null | undefined;
}): MentionOption[] {
const options: MentionOption[] = [
...buildCompanyUserMentionOptions(args.members),
+1
View File
@@ -93,6 +93,7 @@ export const queryKeys = {
joinRequests: (companyId: string, status: string = "pending_approval") =>
["access", "join-requests", companyId, status] as const,
companyMembers: (companyId: string) => ["access", "company-members", companyId] as const,
companyUserDirectory: (companyId: string) => ["access", "company-user-directory", companyId] as const,
adminUsers: (query: string) => ["access", "admin-users", query] as const,
userCompanyAccess: (userId: string) => ["access", "user-company-access", userId] as const,
invite: (token: string) => ["access", "invite", token] as const,
+1
View File
@@ -78,6 +78,7 @@ export function CompanyAccess() {
const refreshAccessData = async () => {
if (!selectedCompanyId) return;
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId, "pending_approval") });
};
+6 -6
View File
@@ -832,19 +832,19 @@ export function Inbox() {
refetchInterval: 5000,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyMembers(selectedCompanyId!),
queryFn: () => accessApi.listMembers(selectedCompanyId!),
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const companyUserLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.members),
[companyMembers?.members],
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const companyUserProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.members),
[companyMembers?.members],
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
+1
View File
@@ -42,6 +42,7 @@ export function JoinRequestQueue() {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!) });
pushToast({ title: "Join request approved", tone: "success" });
},
});