fix: paginate and search member/group pickers (#2768)

This commit is contained in:
Lucas Smith
2026-05-07 15:03:38 +10:00
committed by GitHub
parent bc3aa9c858
commit f66751668a
14 changed files with 522 additions and 136 deletions
@@ -17,8 +17,16 @@ export const findOrganisationGroupsRoute = authenticatedProcedure
.input(ZFindOrganisationGroupsRequestSchema)
.output(ZFindOrganisationGroupsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, types, query, page, perPage, organisationGroupId, organisationRoles } =
input;
const {
organisationId,
types,
query,
page,
perPage,
organisationGroupId,
organisationRoles,
excludeTeamId,
} = input;
const { user } = ctx;
ctx.logger.info({
@@ -36,6 +44,7 @@ export const findOrganisationGroupsRoute = authenticatedProcedure
query,
page,
perPage,
excludeTeamId,
});
});
@@ -48,6 +57,7 @@ type FindOrganisationGroupsOptions = {
query?: string;
page?: number;
perPage?: number;
excludeTeamId?: number;
};
export const findOrganisationGroups = async ({
@@ -59,6 +69,7 @@ export const findOrganisationGroups = async ({
query,
page = 1,
perPage = 10,
excludeTeamId,
}: FindOrganisationGroupsOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
@@ -92,6 +103,14 @@ export const findOrganisationGroups = async ({
};
}
// Exclude organisation groups that already have a team-group entry pointing
// at the given team — i.e. they're already attached.
if (excludeTeamId !== undefined) {
whereClause.teamGroups = {
none: { teamId: excludeTeamId },
};
}
const [data, count] = await Promise.all([
prisma.organisationGroup.findMany({
where: whereClause,
@@ -19,6 +19,12 @@ export const ZFindOrganisationGroupsRequestSchema = ZFindSearchParamsSchema.exte
organisationGroupId: z.string().optional(),
organisationRoles: z.nativeEnum(OrganisationMemberRole).array().optional(),
types: z.nativeEnum(OrganisationGroupType).array().optional(),
/**
* Exclude organisation groups that are already attached to the given team.
* Useful for "add groups to team" pickers so that groups already on the
* team don't appear in the dropdown.
*/
excludeTeamId: z.number().optional(),
});
export const ZFindOrganisationGroupsResponseSchema = ZFindResultResponse.extend({
@@ -28,6 +28,7 @@ export const findOrganisationMembersRoute = authenticatedProcedure
query: input.query,
page: input.page,
perPage: input.perPage,
excludeTeamId: input.excludeTeamId,
});
return {
@@ -55,6 +56,7 @@ type FindOrganisationMembersOptions = {
query?: string;
page?: number;
perPage?: number;
excludeTeamId?: number;
};
export const findOrganisationMembers = async ({
@@ -63,6 +65,7 @@ export const findOrganisationMembers = async ({
query,
page = 1,
perPage = 10,
excludeTeamId,
}: FindOrganisationMembersOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
@@ -95,6 +98,21 @@ export const findOrganisationMembers = async ({
};
}
// Exclude organisation members who are already part of the given team —
// i.e. they belong to an organisation group that has a team-group entry
// pointing at the team.
if (excludeTeamId !== undefined) {
whereClause.organisationGroupMembers = {
none: {
group: {
teamGroups: {
some: { teamId: excludeTeamId },
},
},
},
};
}
const [data, count] = await Promise.all([
prisma.organisationMember.findMany({
where: whereClause,
@@ -17,6 +17,12 @@ import { OrganisationMemberSchema } from '@documenso/prisma/generated/zod/modelS
export const ZFindOrganisationMembersRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
/**
* Exclude organisation members who are already members of the given team.
* Useful for "add members to team" pickers so that members already on the
* team don't appear in the dropdown.
*/
excludeTeamId: z.number().optional(),
});
export const ZFindOrganisationMembersResponseSchema = ZFindResultResponse.extend({
@@ -65,6 +65,8 @@ export const createTeamGroupsRoute = authenticatedProcedure
},
});
// Hard validation — these failures indicate programming or authorisation
// errors and should reject the whole request.
const isValid = groups.every((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
@@ -84,11 +86,6 @@ export const createTeamGroupsRoute = authenticatedProcedure
return false;
}
// Check that the group is not already added to the team.
if (organisationGroup.teamGroups.some((teamGroup) => teamGroup.teamId === teamId)) {
return false;
}
// Check that the user has permission to add the group to the team.
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, group.teamRole)) {
return false;
@@ -103,8 +100,23 @@ export const createTeamGroupsRoute = authenticatedProcedure
});
}
// Silently drop groups already attached to the team. Makes the create
// idempotent for the common race where a group was added between the
// picker fetch and the submit.
const filteredGroups = groups.filter((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
);
return !organisationGroup?.teamGroups.some((teamGroup) => teamGroup.teamId === teamId);
});
if (filteredGroups.length === 0) {
return;
}
await prisma.teamGroup.createMany({
data: groups.map((group) => ({
data: filteredGroups.map((group) => ({
id: generateDatabaseId('team_group'),
teamId,
organisationGroupId: group.organisationGroupId,
@@ -146,15 +146,55 @@ export const createTeamMembers = async ({
});
}
const teamRoleGroupId = (role: TeamMemberRole) =>
match(role)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive();
// Silently drop additions that would duplicate an existing membership in
// the same internal-team role group (the only case that would hit the
// (organisationMemberId, groupId) unique constraint). Members who are
// implicitly part of the team via an INTERNAL_ORGANISATION group are NOT
// dropped, so this still allows assigning an explicit team role on top
// of the inherited org-level membership.
const existingTeamGroupMemberships = await prisma.organisationGroupMember.findMany({
where: {
organisationMemberId: {
in: membersToCreate.map((member) => member.organisationMemberId),
},
groupId: {
in: [
teamMemberGroup.organisationGroupId,
teamManagerGroup.organisationGroupId,
teamAdminGroup.organisationGroupId,
],
},
},
select: { organisationMemberId: true, groupId: true },
});
const existingPairs = new Set(
existingTeamGroupMemberships.map(
({ organisationMemberId, groupId }) => `${organisationMemberId}:${groupId}`,
),
);
const filteredMembersToCreate = membersToCreate.filter(
(member) =>
!existingPairs.has(`${member.organisationMemberId}:${teamRoleGroupId(member.teamRole)}`),
);
if (filteredMembersToCreate.length === 0) {
return;
}
await prisma.organisationGroupMember.createMany({
data: membersToCreate.map((member) => ({
data: filteredMembersToCreate.map((member) => ({
id: generateDatabaseId('group_member'),
organisationMemberId: member.organisationMemberId,
groupId: match(member.teamRole)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive(),
groupId: teamRoleGroupId(member.teamRole),
})),
});
};