From fe6efaad03239700bcc3142801f9fa74dae021e0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 15:26:48 -0500 Subject: [PATCH] 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 --- .../company-user-directory-route.test.ts | 132 ++++++++++++++++++ server/src/routes/access.ts | 33 +++++ ui/src/api/access.ts | 13 ++ .../components/ExecutionParticipantPicker.tsx | 12 +- ui/src/components/IssueProperties.tsx | 12 +- ui/src/components/NewIssueDialog.tsx | 12 +- ui/src/components/NewProjectDialog.tsx | 8 +- ui/src/lib/company-members.test.ts | 23 ++- ui/src/lib/company-members.ts | 21 +-- ui/src/lib/queryKeys.ts | 1 + ui/src/pages/CompanyAccess.tsx | 1 + ui/src/pages/Inbox.tsx | 12 +- ui/src/pages/JoinRequestQueue.tsx | 1 + 13 files changed, 243 insertions(+), 38 deletions(-) create mode 100644 server/src/__tests__/company-user-directory-route.test.ts diff --git a/server/src/__tests__/company-user-directory-route.test.ts b/server/src/__tests__/company-user-directory-route.test.ts new file mode 100644 index 00000000000..c56ea4e0d49 --- /dev/null +++ b/server/src/__tests__/company-user-directory-route.test.ts @@ -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" }, + }, + ], + }); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 7c60eb3480a..1107f69adb9 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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), diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 45eac6fcd57..1bfa65eae1a 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -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(`/companies/${companyId}/members`), + listUserDirectory: (companyId: string) => + api.get(`/companies/${companyId}/user-directory`), + updateMember: ( companyId: string, memberId: string, diff --git a/ui/src/components/ExecutionParticipantPicker.tsx b/ui/src/components/ExecutionParticipantPicker.tsx index b1aa22c7468..cf21a5c35f5 100644 --- a/ui/src/components/ExecutionParticipantPicker.tsx +++ b/ui/src/components/ExecutionParticipantPicker.tsx @@ -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) => diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c4ea7c2318d..ffc96c41a8f 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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 diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 80b0aa8f73d..1418aaceb12 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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( () => [ ...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( () => diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 01024f922c9..ad4aee14dd3 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -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(() => { return buildMarkdownMentionOptions({ agents, - members: companyMembers?.members, + members: companyMembers?.users, }); - }, [agents, companyMembers?.members]); + }, [agents, companyMembers?.users]); const createProject = useMutation({ mutationFn: (data: Record) => diff --git a/ui/src/lib/company-members.test.ts b/ui/src/lib/company-members.test.ts index a9d9c281edc..2afc4fa3b82 100644 --- a/ui/src/lib/company-members.test.ts +++ b/ui/src/lib/company-members.test.ts @@ -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" }, + ]); + }); }); diff --git a/ui/src/lib/company-members.ts b/ui/src/lib/company-members.ts index 928b516a9a9..f0f908f9232 100644 --- a/ui/src/lib/company-members.ts +++ b/ui/src/lib/company-members.ts @@ -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 + | CompanyUserDirectoryEntry; + function fallbackUserLabel(userId: string): string { if (userId === "local-board") return "Board"; return userId.slice(0, 5); } -function baseMemberLabel(member: Pick): string { +function baseMemberLabel(member: Pick): 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): s return fallbackUserLabel(member.principalId); } -function activeUniqueMembers(members: CompanyMember[] | null | undefined) { - const byId = new Map(); +function activeUniqueMembers(members: CompanyUserRecord[] | null | undefined) { + const byId = new Map(); 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 { +export function buildCompanyUserLabelMap(members: CompanyUserRecord[] | null | undefined): Map { const labels = new Map(); 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 { const profiles = new Map(); 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 }, ): 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> | null | undefined; projects?: Array> | null | undefined; - members?: CompanyMember[] | null | undefined; + members?: CompanyUserRecord[] | null | undefined; }): MentionOption[] { const options: MentionOption[] = [ ...buildCompanyUserMentionOptions(args.members), diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 3ddff1a6b19..08fbc1fbb62 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index eb6126bc5a1..80f829df003 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -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") }); }; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 0986a416408..ef75d2ca6b7 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -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]); diff --git a/ui/src/pages/JoinRequestQueue.tsx b/ui/src/pages/JoinRequestQueue.tsx index 73000e98ab5..29662d9a161 100644 --- a/ui/src/pages/JoinRequestQueue.tsx +++ b/ui/src/pages/JoinRequestQueue.tsx @@ -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" }); }, });