fix: Exclusion attribute filter (#27669)

* Fix exclusion filter - include all team members

* Fix display when members aren't saved in the DB

* Update tests

* Undo change to member switch
This commit is contained in:
Joe Au-Yeung
2026-02-06 14:06:31 +00:00
committed by GitHub
parent 38492d5839
commit bc0ff0da8e
3 changed files with 501 additions and 56 deletions
@@ -734,7 +734,7 @@ describe("getAttributes", () => {
it("should handle empty attributes gracefully", async () => {
const team = await createMockTeam({ orgId });
await createMockUserHavingMembershipWithBothTeamAndOrg({
const { user } = await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
@@ -752,8 +752,13 @@ describe("getAttributes", () => {
const { attributesOfTheOrg, attributesAssignedToTeamMembersWithOptions } =
await getAttributesAssignmentData({ teamId: team.id, orgId });
// No assignments since there are no options to assign
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(0);
// Team member should still be included (with empty attributes) even though there are no assignments.
// This is important for "not any in" filters where members without the attribute should match.
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
expect(attributesAssignedToTeamMembersWithOptions[0]).toEqual({
userId: user.id,
attributes: {},
});
expect(attributesOfTheOrg).toHaveLength(1);
});
@@ -985,6 +990,94 @@ describe("getAttributes", () => {
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(1);
expect(Object.keys(attributesAssignedToTeamMembersWithOptions[0].attributes)).toEqual(["attr1"]);
});
it("should include team members without any assignment for queried attributes (important for 'not any in' filters)", async () => {
const team = await createMockTeam({ orgId });
// User 1 has Department assignment
const { user: user1, orgMembership: orgMembership1 } =
await createMockUserHavingMembershipWithBothTeamAndOrg({
orgId,
teamId: team.id,
});
// User 2 only has Location assignment (no Department)
const user2 = await prismock.user.create({
data: { name: "User 2", email: "user2@test.com" },
});
const orgMembership2 = await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: orgId, userId: user2.id },
});
await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: team.id, userId: user2.id },
});
// User 3 has no attribute assignments at all
const user3 = await prismock.user.create({
data: { name: "User 3", email: "user3@test.com" },
});
await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: orgId, userId: user3.id },
});
await prismock.membership.create({
data: { role: MembershipRole.MEMBER, disableImpersonation: false, accepted: true, teamId: team.id, userId: user3.id },
});
await createMockAttribute({
orgId,
id: "dept-attr",
name: "Department",
slug: "department",
type: AttributeType.SINGLE_SELECT,
options: [
{ id: "dept-eng", value: "Engineering", slug: "engineering", isGroup: false, contains: [] },
{ id: "dept-sales", value: "Sales", slug: "sales", isGroup: false, contains: [] },
],
});
await createMockAttribute({
orgId,
id: "loc-attr",
name: "Location",
slug: "location",
type: AttributeType.SINGLE_SELECT,
options: [{ id: "loc-nyc", value: "NYC", slug: "nyc", isGroup: false, contains: [] }],
});
// User 1: Department = Engineering
await createMockAttributeAssignment({
orgMembershipId: orgMembership1.id,
attributeOptionId: "dept-eng",
});
// User 2: Location = NYC (no Department)
await createMockAttributeAssignment({
orgMembershipId: orgMembership2.id,
attributeOptionId: "loc-nyc",
});
// Query only for Department attribute (simulating "Department not any in [Sales]" filter)
const { attributesAssignedToTeamMembersWithOptions } = await getAttributesAssignmentData({
teamId: team.id,
orgId,
attributeIds: ["dept-attr"],
});
// All 3 team members should be included, even those without Department assignment
expect(attributesAssignedToTeamMembersWithOptions).toHaveLength(3);
const user1Data = attributesAssignedToTeamMembersWithOptions.find((m) => m.userId === user1.id);
const user2Data = attributesAssignedToTeamMembersWithOptions.find((m) => m.userId === user2.id);
const user3Data = attributesAssignedToTeamMembersWithOptions.find((m) => m.userId === user3.id);
// User 1 has Department assignment
expect(user1Data?.attributes["dept-attr"]).toBeDefined();
expect(user1Data?.attributes["dept-attr"].attributeOption).toMatchObject({ value: "Engineering" });
// User 2 and User 3 should be included with empty attributes (for "not any in" evaluation)
expect(user2Data?.attributes).toEqual({});
expect(user3Data?.attributes).toEqual({});
});
});
});
@@ -129,70 +129,81 @@ async function _findMembershipsForBothOrgAndTeam({
function _prepareAssignmentData({
assignmentsForTheTeam,
lookupMaps,
allTeamMemberIds,
}: {
assignmentsForTheTeam: AssignmentForTheTeam[];
lookupMaps: AttributeLookupMaps;
/** All team member user IDs - ensures members without assignments are included */
allTeamMemberIds: UserId[];
}) {
const { optionIdToOption, attributeIdToOptions } = lookupMaps;
const teamMembersThatHaveOptionAssigned = assignmentsForTheTeam.reduce(
(acc, attributeToUser) => {
const userId = attributeToUser.userId;
const attributeOption = attributeToUser.attributeOption;
const attribute = attributeToUser.attribute;
// Group assignments by userId for O(1) lookup
const assignmentsByUserId = new Map<UserId, AssignmentForTheTeam[]>();
for (const assignment of assignmentsForTheTeam) {
const existing = assignmentsByUserId.get(assignment.userId);
if (existing) {
existing.push(assignment);
} else {
assignmentsByUserId.set(assignment.userId, [assignment]);
}
}
if (!acc[userId]) {
acc[userId] = { userId, attributes: {} };
}
// Iterate over all team members once, applying their assignments if any.
// This ensures members without assignments are still included (important for
// "not any in" filters where members without the attribute should match).
const result: {
userId: UserId;
attributes: Record<AttributeId, AttributeOptionValueWithType>;
}[] = [];
const attributes = acc[userId].attributes;
const currentAttributeOptionValue =
attributes[attribute.id]?.attributeOption;
const newAttributeOptionValue = {
isGroup: attributeOption.isGroup,
value: attributeOption.value,
contains: tranformContains({
contains: attributeOption.contains,
attribute,
}),
};
for (const userId of allTeamMemberIds) {
const memberData: {
userId: UserId;
attributes: Record<AttributeId, AttributeOptionValueWithType>;
} = { userId, attributes: {} };
if (currentAttributeOptionValue instanceof Array) {
attributes[attribute.id].attributeOption = [
...currentAttributeOptionValue,
newAttributeOptionValue,
];
} else if (currentAttributeOptionValue) {
attributes[attribute.id].attributeOption = [
currentAttributeOptionValue,
{
isGroup: attributeOption.isGroup,
value: attributeOption.value,
contains: tranformContains({
contains: attributeOption.contains,
attribute,
}),
},
];
} else {
// Set the first value
attributes[attribute.id] = {
type: attribute.type,
attributeOption: newAttributeOptionValue,
const userAssignments = assignmentsByUserId.get(userId);
if (userAssignments) {
for (const attributeToUser of userAssignments) {
const attributeOption = attributeToUser.attributeOption;
const attribute = attributeToUser.attribute;
const attributes = memberData.attributes;
const currentAttributeOptionValue = attributes[attribute.id]?.attributeOption;
const newAttributeOptionValue = {
isGroup: attributeOption.isGroup,
value: attributeOption.value,
contains: tranformContains({
contains: attributeOption.contains,
attribute,
}),
};
}
return acc;
},
{} as Record<
UserId,
{
userId: UserId;
attributes: Record<AttributeId, AttributeOptionValueWithType>;
}
>
);
return Object.values(teamMembersThatHaveOptionAssigned);
if (currentAttributeOptionValue instanceof Array) {
attributes[attribute.id].attributeOption = [
...currentAttributeOptionValue,
newAttributeOptionValue,
];
} else if (currentAttributeOptionValue) {
attributes[attribute.id].attributeOption = [
currentAttributeOptionValue,
newAttributeOptionValue,
];
} else {
// Set the first value
attributes[attribute.id] = {
type: attribute.type,
attributeOption: newAttributeOptionValue,
};
}
}
}
result.push(memberData);
}
return result;
/**
* Transforms ["optionId1", "optionId2"] to [{
@@ -508,9 +519,24 @@ export async function getAttributesAssignmentData({
lookupMaps,
});
const allTeamMemberIds = Array.from(orgMembershipToUserIdForTeamMembers.values());
logger.debug("getAttributesAssignmentData", {
teamId,
orgId,
attributeIds,
allTeamMemberIdsCount: allTeamMemberIds.length,
assignmentsForTheTeamCount: assignmentsForTheTeam.length,
});
const attributesAssignedToTeamMembersWithOptions = _prepareAssignmentData({
assignmentsForTheTeam,
lookupMaps,
allTeamMemberIds,
});
logger.debug("getAttributesAssignmentData result", {
attributesAssignedToTeamMembersWithOptionsCount: attributesAssignedToTeamMembersWithOptions.length,
});
return {
@@ -1505,4 +1505,330 @@ describe("findTeamMembersMatchingAttributeLogic", () => {
);
});
});
describe("negation operators with users who have no attribute assignment", () => {
// These tests verify that users without an attribute assignment are correctly
// included in the evaluation when using negation operators. This is important
// because users without the attribute should match "not" conditions.
const DepartmentAttribute = {
id: "dept-attr",
name: "Department",
type: "SINGLE_SELECT" as const,
slug: "department",
options: [
{ id: "dept-sales", value: "Sales", slug: "sales" },
{ id: "dept-eng", value: "Engineering", slug: "engineering" },
{ id: "dept-marketing", value: "Marketing", slug: "marketing" },
],
};
const LocationsAttribute = {
id: "locs-attr",
name: "Locations",
type: "MULTI_SELECT" as const,
slug: "locations",
options: [
{ id: "loc-nyc", value: "NYC", slug: "nyc" },
{ id: "loc-la", value: "LA", slug: "la" },
{ id: "loc-chicago", value: "Chicago", slug: "chicago" },
],
};
describe("select_not_equals", () => {
it("should match users without the attribute (undefined != 'Sales' is true)", async () => {
mockAttributesScenario({
attributes: [DepartmentAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [DepartmentAttribute.id]: "Sales" } },
{ userId: 2, attributes: { [DepartmentAttribute.id]: "Engineering" } },
{ userId: 3, attributes: {} }, // No department assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: DepartmentAttribute.id,
value: ["dept-sales"],
operator: "select_not_equals",
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// User 1 (Sales) should NOT match
// User 2 (Engineering) should match
// User 3 (no dept) should match (undefined != "Sales" is true)
expect(result).toEqual(
expect.arrayContaining([
{ userId: 2, result: RaqbLogicResult.MATCH },
{ userId: 3, result: RaqbLogicResult.MATCH },
])
);
expect(result).not.toContainEqual({ userId: 1, result: RaqbLogicResult.MATCH });
});
});
describe("select_not_any_in", () => {
it("should match users without the attribute (undefined not in ['Sales', 'Marketing'] is true)", async () => {
mockAttributesScenario({
attributes: [DepartmentAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [DepartmentAttribute.id]: "Sales" } },
{ userId: 2, attributes: { [DepartmentAttribute.id]: "Engineering" } },
{ userId: 3, attributes: {} }, // No department assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: DepartmentAttribute.id,
value: [["dept-sales", "dept-marketing"]],
operator: "select_not_any_in",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// User 1 (Sales) should NOT match (Sales is in the excluded list)
// User 2 (Engineering) should match (Engineering is not in excluded list)
// User 3 (no dept) should match (undefined is not in excluded list)
expect(result).toEqual(
expect.arrayContaining([
{ userId: 2, result: RaqbLogicResult.MATCH },
{ userId: 3, result: RaqbLogicResult.MATCH },
])
);
expect(result).not.toContainEqual({ userId: 1, result: RaqbLogicResult.MATCH });
});
});
describe("multiselect_not_equals (!all)", () => {
it("should match users without the attribute (not all of undefined in values is true)", async () => {
mockAttributesScenario({
attributes: [LocationsAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [LocationsAttribute.id]: ["NYC", "LA"] } },
{ userId: 2, attributes: { [LocationsAttribute.id]: ["Chicago"] } },
{ userId: 3, attributes: {} }, // No locations assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: LocationsAttribute.id,
value: [["loc-nyc", "loc-la"]],
operator: "multiselect_not_equals",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// User 1 (NYC, LA) should NOT match (all their values are in the list)
// User 2 (Chicago) should match (Chicago is not in the list)
// User 3 (no locations) should match (undefined means not all values match)
expect(result).toEqual(
expect.arrayContaining([
{ userId: 2, result: RaqbLogicResult.MATCH },
{ userId: 3, result: RaqbLogicResult.MATCH },
])
);
expect(result).not.toContainEqual({ userId: 1, result: RaqbLogicResult.MATCH });
});
});
describe("multiselect_not_some_in (!some)", () => {
it("should match users without the attribute (not some of undefined in values is true)", async () => {
mockAttributesScenario({
attributes: [LocationsAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [LocationsAttribute.id]: ["NYC"] } },
{ userId: 2, attributes: { [LocationsAttribute.id]: ["Chicago"] } },
{ userId: 3, attributes: {} }, // No locations assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: LocationsAttribute.id,
value: [["loc-nyc", "loc-la"]],
operator: "multiselect_not_some_in",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// User 1 (NYC) should NOT match (NYC is in the list)
// User 2 (Chicago) should match (Chicago is not in the list)
// User 3 (no locations) should match (undefined means none of their values are in the list)
expect(result).toEqual(
expect.arrayContaining([
{ userId: 2, result: RaqbLogicResult.MATCH },
{ userId: 3, result: RaqbLogicResult.MATCH },
])
);
expect(result).not.toContainEqual({ userId: 1, result: RaqbLogicResult.MATCH });
});
});
describe("positive operators should NOT match users without the attribute", () => {
it("select_equals should not match users without the attribute", async () => {
mockAttributesScenario({
attributes: [DepartmentAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [DepartmentAttribute.id]: "Sales" } },
{ userId: 2, attributes: {} }, // No department assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: DepartmentAttribute.id,
value: ["dept-sales"],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// Only User 1 should match
expect(result).toEqual([{ userId: 1, result: RaqbLogicResult.MATCH }]);
});
it("select_any_in should not match users without the attribute", async () => {
mockAttributesScenario({
attributes: [DepartmentAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [DepartmentAttribute.id]: "Sales" } },
{ userId: 2, attributes: {} }, // No department assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: DepartmentAttribute.id,
value: [["dept-sales", "dept-marketing"]],
operator: "select_any_in",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// Only User 1 should match
expect(result).toEqual([{ userId: 1, result: RaqbLogicResult.MATCH }]);
});
it("multiselect_some_in should not match users without the attribute", async () => {
mockAttributesScenario({
attributes: [LocationsAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [LocationsAttribute.id]: ["NYC"] } },
{ userId: 2, attributes: {} }, // No locations assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: LocationsAttribute.id,
value: [["loc-nyc", "loc-la"]],
operator: "multiselect_some_in",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// Only User 1 should match
expect(result).toEqual([{ userId: 1, result: RaqbLogicResult.MATCH }]);
});
it("multiselect_equals (all) should not match users without the attribute", async () => {
mockAttributesScenario({
attributes: [LocationsAttribute],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [LocationsAttribute.id]: ["NYC", "LA"] } },
{ userId: 2, attributes: {} }, // No locations assigned
],
});
const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: LocationsAttribute.id,
value: [["loc-nyc", "loc-la"]],
operator: "multiselect_equals",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({
dynamicFieldValueOperands: { fields: [], response: {} },
attributesQueryValue,
teamId: 1,
orgId,
});
// Only User 1 should match
expect(result).toEqual([{ userId: 1, result: RaqbLogicResult.MATCH }]);
});
});
// Note: is_null and is_not_null operators are listed in the multiselect operators array
// but their definitions are commented out in BasicConfig.ts (lines 158-171).
// These operators are not currently supported for attributes.
});
});