mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
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" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>(
|
||||
() =>
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") });
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user