From 5fba2a752e77ba8155164cc45126b09c1ca7d059 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 5 Feb 2026 11:51:58 -0500 Subject: [PATCH 1/5] Fix exclusion filter - include all team members --- .../features/attributes/lib/getAttributes.ts | 134 +++++++++++------- 1 file changed, 80 insertions(+), 54 deletions(-) diff --git a/packages/features/attributes/lib/getAttributes.ts b/packages/features/attributes/lib/getAttributes.ts index 40449f58d88061..b393022c782a6f 100644 --- a/packages/features/attributes/lib/getAttributes.ts +++ b/packages/features/attributes/lib/getAttributes.ts @@ -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; - - if (!acc[userId]) { - acc[userId] = { userId, attributes: {} }; - } - - 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, - }), - }; + // Group assignments by userId for O(1) lookup + const assignmentsByUserId = new Map(); + for (const assignment of assignmentsForTheTeam) { + const existing = assignmentsByUserId.get(assignment.userId); + if (existing) { + existing.push(assignment); + } else { + assignmentsByUserId.set(assignment.userId, [assignment]); + } + } - 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, + // 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; + }[] = []; + + for (const userId of allTeamMemberIds) { + const memberData: { + userId: UserId; + attributes: Record; + } = { userId, attributes: {} }; + + 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, + }), }; + + 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, + }; + } } - return acc; - }, - {} as Record< - UserId, - { - userId: UserId; - attributes: Record; - } - > - ); + } + + result.push(memberData); + } - return Object.values(teamMembersThatHaveOptionAssigned); + 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 { From 055d683b979b9c50f43bedb8a6ed1ecdb3a8b82b Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 5 Feb 2026 11:56:08 -0500 Subject: [PATCH 2/5] Fix display when members aren't saved in the DB --- apps/web/modules/event-types/components/AddMembersWithSwitch.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx index 68b82fe87e1475..1264d0536995f1 100644 --- a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx +++ b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx @@ -303,7 +303,6 @@ export function AddMembersWithSwitch({ setAssignRRMembersUsingSegment={setAssignRRMembersUsingSegment} rrSegmentQueryValue={rrSegmentQueryValue} setRrSegmentQueryValue={setRrSegmentQueryValue} - filterMemberIds={value.filter((host) => !host.isFixed).map((host) => host.userId)} /> )} From 481cdd3689b4bb9cd6a6568935144998695970b9 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung Date: Thu, 5 Feb 2026 11:57:58 -0500 Subject: [PATCH 3/5] Update tests --- .../attributes/lib/getAttributes.test.ts | 99 +++++- ...dTeamMembersMatchingAttributeLogic.test.ts | 326 ++++++++++++++++++ 2 files changed, 422 insertions(+), 3 deletions(-) diff --git a/packages/features/attributes/lib/getAttributes.test.ts b/packages/features/attributes/lib/getAttributes.test.ts index db3281afa6dc2b..4e91b6fe2a573b 100644 --- a/packages/features/attributes/lib/getAttributes.test.ts +++ b/packages/features/attributes/lib/getAttributes.test.ts @@ -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({}); + }); }); }); diff --git a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts index 728d51536690b4..55a76b00d8f13c 100644 --- a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts +++ b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts @@ -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. + }); }); From 39d7fbe0852974e5494ceb01b87972fe828f3e1c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:58:40 +0000 Subject: [PATCH 4/5] test: add missing negation operator tests for TEXT, NUMBER, and compound rules Co-Authored-By: hariom@cal.com --- ...dTeamMembersMatchingAttributeLogic.test.ts | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts index 55a76b00d8f13c..0d963cd419dcec 100644 --- a/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts +++ b/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.test.ts @@ -1831,4 +1831,263 @@ describe("findTeamMembersMatchingAttributeLogic", () => { // but their definitions are commented out in BasicConfig.ts (lines 158-171). // These operators are not currently supported for attributes. }); + + describe("negation operators with TEXT and NUMBER attribute types", () => { + const JobTitleAttribute = { + id: "job-title-attr", + name: "Job Title", + type: "TEXT" as const, + slug: "job-title", + options: [], + }; + + const ExperienceAttribute = { + id: "exp-attr", + name: "Experience Years", + type: "NUMBER" as const, + slug: "experience-years", + options: [], + }; + + describe("TEXT not_equal", () => { + it("should match users without the attribute (undefined != 'sales manager' is true)", async () => { + mockAttributesScenario({ + attributes: [JobTitleAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [JobTitleAttribute.id]: "Sales Manager" } }, + { userId: 2, attributes: { [JobTitleAttribute.id]: "Engineer" } }, + { userId: 3, attributes: {} }, + ], + }); + + const attributesQueryValue = buildQueryValue({ + rules: [ + { + raqbFieldId: JobTitleAttribute.id, + value: ["sales manager"], + operator: "not_equal", + valueSrc: ["value"], + valueType: ["text"], + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic( + { + dynamicFieldValueOperands: { fields: [], response: {} }, + attributesQueryValue, + teamId: 1, + orgId, + } + ); + + 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("TEXT not_like", () => { + it("should match users without the attribute (undefined not contains 'engineer' is true)", async () => { + mockAttributesScenario({ + attributes: [JobTitleAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [JobTitleAttribute.id]: "Senior Engineer" } }, + { userId: 2, attributes: { [JobTitleAttribute.id]: "Designer" } }, + { userId: 3, attributes: {} }, + ], + }); + + const attributesQueryValue = buildQueryValue({ + rules: [ + { + raqbFieldId: JobTitleAttribute.id, + value: ["engineer"], + operator: "not_like", + valueSrc: ["value"], + valueType: ["text"], + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic( + { + dynamicFieldValueOperands: { fields: [], response: {} }, + attributesQueryValue, + teamId: 1, + orgId, + } + ); + + 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("NUMBER not_equal", () => { + it("should match users without the attribute (undefined != '5' is true)", async () => { + mockAttributesScenario({ + attributes: [ExperienceAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [ExperienceAttribute.id]: "5" } }, + { userId: 2, attributes: { [ExperienceAttribute.id]: "10" } }, + { userId: 3, attributes: {} }, + ], + }); + + const attributesQueryValue = buildQueryValue({ + rules: [ + { + raqbFieldId: ExperienceAttribute.id, + value: ["5"], + operator: "not_equal", + valueSrc: ["value"], + valueType: ["number"], + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic( + { + dynamicFieldValueOperands: { fields: [], response: {} }, + attributesQueryValue, + teamId: 1, + orgId, + } + ); + + 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("compound rules with users missing some attributes", () => { + const DepartmentAttribute = { + id: "dept-attr-2", + name: "Department", + type: "SINGLE_SELECT" as const, + slug: "department", + options: [ + { id: "dept-sales-2", value: "Sales", slug: "sales" }, + { id: "dept-eng-2", value: "Engineering", slug: "engineering" }, + ], + }; + + const LocationAttribute = { + id: "loc-attr-2", + name: "Location", + type: "SINGLE_SELECT" as const, + slug: "location", + options: [ + { id: "loc-nyc-2", value: "NYC", slug: "nyc" }, + { id: "loc-la-2", value: "LA", slug: "la" }, + ], + }; + + it("positive AND negation: should match user with one attribute but missing the negated one", async () => { + mockAttributesScenario({ + attributes: [DepartmentAttribute, LocationAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + { + userId: 1, + attributes: { [DepartmentAttribute.id]: "Sales", [LocationAttribute.id]: "NYC" }, + }, + { userId: 2, attributes: { [DepartmentAttribute.id]: "Sales" } }, + { userId: 3, attributes: {} }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: DepartmentAttribute.id, + value: ["dept-sales-2"], + operator: "select_equals", + }, + { + raqbFieldId: LocationAttribute.id, + value: ["loc-nyc-2"], + operator: "select_not_equals", + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], response: {} }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + // User 1: Dept=Sales (== matches) AND Location=NYC (!= fails) → NO MATCH + // User 2: Dept=Sales (== matches) AND Location=undefined (!= matches) → MATCH + // User 3: no Dept (== fails) → NO MATCH (AND short-circuits) + expect(result).toEqual([{ userId: 2, result: RaqbLogicResult.MATCH }]); + }); + + it("multiple negation rules: should match users missing different attributes", async () => { + mockAttributesScenario({ + attributes: [DepartmentAttribute, LocationAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + { + userId: 1, + attributes: { [DepartmentAttribute.id]: "Sales", [LocationAttribute.id]: "NYC" }, + }, + { userId: 2, attributes: { [DepartmentAttribute.id]: "Engineering" } }, + { userId: 3, attributes: { [LocationAttribute.id]: "LA" } }, + { userId: 4, attributes: {} }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: DepartmentAttribute.id, + value: ["dept-sales-2"], + operator: "select_not_equals", + }, + { + raqbFieldId: LocationAttribute.id, + value: ["loc-nyc-2"], + operator: "select_not_equals", + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { fields: [], response: {} }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + // User 1: Dept=Sales (!= fails) → NO MATCH + // User 2: Dept=Engineering (!= matches) AND Location=undefined (!= matches) → MATCH + // User 3: Dept=undefined (!= matches) AND Location=LA (!= matches) → MATCH + // User 4: both undefined (both != match) → MATCH + expect(result).toEqual( + expect.arrayContaining([ + { userId: 2, result: RaqbLogicResult.MATCH }, + { userId: 3, result: RaqbLogicResult.MATCH }, + { userId: 4, result: RaqbLogicResult.MATCH }, + ]) + ); + expect(result).not.toContainEqual({ userId: 1, result: RaqbLogicResult.MATCH }); + }); + }); }); From cab95988c16992f239c321592b4e9f7a1dc674b4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:55:21 +0000 Subject: [PATCH 5/5] fix: revert non-intentional changes to AddMembersWithSwitch.tsx Co-Authored-By: hariom@cal.com --- apps/web/modules/event-types/components/AddMembersWithSwitch.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx index 1abf3df4382911..c0938e7a289d48 100644 --- a/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx +++ b/apps/web/modules/event-types/components/AddMembersWithSwitch.tsx @@ -303,6 +303,7 @@ export function AddMembersWithSwitch({ setAssignRRMembersUsingSegment={setAssignRRMembersUsingSegment} rrSegmentQueryValue={rrSegmentQueryValue} setRrSegmentQueryValue={setRrSegmentQueryValue} + filterMemberIds={value.filter((host) => !host.isFixed).map((host) => host.userId)} /> )}