From 7e76770ba6dc51a3de03c8256916d693b5f1adcf Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Fri, 6 Dec 2024 15:17:50 +0530 Subject: [PATCH] feat: Okta(SCIM2.0) -> Attributes Sync and Group Option support (#17805) --- .../web/pages/api/scim/v2.0/[...directory].ts | 20 +- apps/web/public/static/locales/en/common.json | 4 + apps/web/test/lib/generateCsv.test.ts | 8 +- .../trpc/getAttributesForTeam.handler.ts | 2 +- .../getAttributesFromScimPayload.test.ts | 164 +++ .../dsync/lib/getAttributesFromScimPayload.ts | 115 ++ .../ee/dsync/lib/handleGroupEvents.ts | 5 + .../features/ee/dsync/lib/handleUserEvents.ts | 58 + .../settings/attributes/AttributesForm.tsx | 274 ++++- .../__tests__/AttributeForm.test.ts | 78 ++ .../attributes/attributes-create-view.tsx | 1 + .../attributes/attributes-edit-view.tsx | 11 +- .../AssignmentReasonRecorder.ts | 2 +- .../BulkActions/MassAssignAttributes.tsx | 4 + .../UserTable/EditSheet/EditUserForm.tsx | 91 +- .../components/UserTable/UserListTable.tsx | 23 +- .../users/components/UserTable/types.ts | 1 + ...dTeamMembersMatchingAttributeLogic.test.ts | 42 +- .../findTeamMembersMatchingAttributeLogic.ts | 8 +- packages/lib/raqb/getAttributes.ts | 155 --- packages/lib/raqb/raqbUtils.test.ts | 90 ++ packages/lib/raqb/raqbUtils.ts | 66 +- packages/lib/raqb/types.d.ts | 14 - packages/lib/server/repository/attribute.ts | 27 + .../lib/server/repository/attributeOption.ts | 31 + .../lib/server/repository/attributeToUser.ts | 31 + packages/lib/service/TODO.md | 39 + .../server/assignValueToUser.test.ts | 1042 +++++++++++++++++ .../attribute/server/assignValueToUser.ts | 511 ++++++++ .../attribute/server/getAttributes.test.ts | 446 +++++++ .../service/attribute/server/getAttributes.ts | 233 ++++ .../attribute/server/scim-attribute-sync.md | 21 + .../lib/service/attribute/server/utils.ts | 87 ++ packages/lib/service/attribute/types.d.ts | 18 + packages/lib/service/attribute/utils.ts | 87 ++ .../migration.sql | 26 + packages/prisma/schema.prisma | 27 + .../assignUserToAttribute.handler.ts | 20 +- .../viewer/attributes/create.handler.ts | 1 + .../viewer/attributes/create.schema.ts | 1 + .../routers/viewer/attributes/edit.handler.ts | 33 +- .../routers/viewer/attributes/edit.schema.ts | 10 +- .../routers/viewer/attributes/get.handler.ts | 12 +- .../viewer/attributes/getByUserId.handler.ts | 12 +- .../organizations/listMembers.handler.ts | 40 +- vitest.workspace.ts | 4 +- 46 files changed, 3688 insertions(+), 307 deletions(-) create mode 100644 packages/features/ee/dsync/lib/__tests__/getAttributesFromScimPayload.test.ts create mode 100644 packages/features/ee/dsync/lib/getAttributesFromScimPayload.ts create mode 100644 packages/features/ee/organizations/pages/settings/attributes/__tests__/AttributeForm.test.ts delete mode 100644 packages/lib/raqb/getAttributes.ts create mode 100644 packages/lib/raqb/raqbUtils.test.ts create mode 100644 packages/lib/server/repository/attribute.ts create mode 100644 packages/lib/server/repository/attributeOption.ts create mode 100644 packages/lib/server/repository/attributeToUser.ts create mode 100644 packages/lib/service/TODO.md create mode 100644 packages/lib/service/attribute/server/assignValueToUser.test.ts create mode 100644 packages/lib/service/attribute/server/assignValueToUser.ts create mode 100644 packages/lib/service/attribute/server/getAttributes.test.ts create mode 100644 packages/lib/service/attribute/server/getAttributes.ts create mode 100644 packages/lib/service/attribute/server/scim-attribute-sync.md create mode 100644 packages/lib/service/attribute/server/utils.ts create mode 100644 packages/lib/service/attribute/types.d.ts create mode 100644 packages/lib/service/attribute/utils.ts create mode 100644 packages/prisma/migrations/20241126132214_scim_attributes_sync_related_attrs_added/migration.sql diff --git a/apps/web/pages/api/scim/v2.0/[...directory].ts b/apps/web/pages/api/scim/v2.0/[...directory].ts index b20f9dc6ed..25098f4237 100644 --- a/apps/web/pages/api/scim/v2.0/[...directory].ts +++ b/apps/web/pages/api/scim/v2.0/[...directory].ts @@ -5,19 +5,27 @@ import handleGroupEvents from "@calcom/features/ee/dsync/lib/handleGroupEvents"; import handleUserEvents from "@calcom/features/ee/dsync/lib/handleUserEvents"; import jackson from "@calcom/features/ee/sso/lib/jackson"; import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; +const log = logger.getSubLogger({ prefix: ["[scim]"] }); + // This is the handler for the SCIM API requests export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const log = logger.getSubLogger({ prefix: ["[scim]"] }); const { dsyncController } = await jackson(); - + log.debug("Request from SCIM", { + method: req.method, + url: req.url, + headers: req.headers, + query: req.query, + body: req.body, + cookies: req.cookies, + }); const { method, query, body } = req; const [directoryId, path, resourceId] = query.directory as string[]; let responseBody: object | undefined = undefined; - if (body) { try { responseBody = JSON.parse(body); @@ -43,6 +51,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { status, data } = await dsyncController.requests.handle(request, handleEvents); + log.debug("Response to SCIM", { + status, + data, + }); + res.status(status).json(data); } @@ -55,6 +68,7 @@ export const extractAuthToken = (req: NextApiRequest): string | null => { // Handle the SCIM events const handleEvents = async (event: DirectorySyncEvent) => { + log.debug("handleEvents", safeStringify(event)); const dSyncData = await prisma.dSyncData.findFirst({ where: { directoryId: event.directory_id, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d5b0bfe5b3..4cd6e36d0a 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2834,5 +2834,9 @@ "multi_select": "Multi Select", "group_meeting": "Group Meeting", "salesforce_ignore_guests": "Do not create new records for guests added to the booking", + "lock_attribute_for_assignment": "Lock for assignment", + "lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync", + "attribute_edited_successfully": "Attribute edited successfully", + "new_group_option": "New group option", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts index d6422c0acc..dae38a4f1a 100644 --- a/apps/web/test/lib/generateCsv.test.ts +++ b/apps/web/test/lib/generateCsv.test.ts @@ -91,7 +91,7 @@ describe("generate Csv for Org Users Table @test", () => { { ...mockUser, teams: [{ id: 1, name: "Team1", slug: "team1" }], - attributes: [{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1" }], + attributes: [{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1", contains: [] }], }, ]; @@ -115,8 +115,8 @@ describe("generate Csv for Org Users Table @test", () => { ...mockUser, teams: [{ id: 1, name: "Team1", slug: "team1" }], attributes: [ - { id: "1", attributeId: "attr1", value: "value1", slug: "slug1" }, - { id: "2", attributeId: "attr1", value: "value2", slug: "slug1" }, + { id: "1", attributeId: "attr1", value: "value1", slug: "slug1", contains: [] }, + { id: "2", attributeId: "attr1", value: "value2", slug: "slug1", contains: [] }, ], }, ]; @@ -166,7 +166,7 @@ describe("generate Csv for Org Users Table @test", () => { { ...mockUser, teams: [{ id: 1, name: "Team,1", slug: "team1" }], - attributes: [{ id: "1", attributeId: "attr1", value: "value,1", slug: "slug1" }], + attributes: [{ id: "1", attributeId: "attr1", value: "value,1", slug: "slug1", contains: [] }], }, ]; diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts index f6b8e51bd3..0e48feee4b 100644 --- a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts +++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts @@ -1,5 +1,5 @@ -import { getAttributesForTeam } from "@calcom/lib/raqb/getAttributes"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { getAttributesForTeam } from "@calcom/lib/service/attribute/server/getAttributes"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; diff --git a/packages/features/ee/dsync/lib/__tests__/getAttributesFromScimPayload.test.ts b/packages/features/ee/dsync/lib/__tests__/getAttributesFromScimPayload.test.ts new file mode 100644 index 0000000000..4c1cdcac5c --- /dev/null +++ b/packages/features/ee/dsync/lib/__tests__/getAttributesFromScimPayload.test.ts @@ -0,0 +1,164 @@ +import type { DirectorySyncEvent } from "@boxyhq/saml-jackson"; +import { describe, expect, it } from "vitest"; + +import getAttributesFromScimPayload from "../getAttributesFromScimPayload"; + +describe("getAttributesFromScimPayload", () => { + it("should return empty object for unsupported events", () => { + const event = { + event: "user.deleted", + data: { raw: { schemas: [] } }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({}); + }); + + it("should extract string attributes from custom schemas", () => { + const event = { + event: "user.created", + data: { + raw: { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User", "custom:enterprise"], + "custom:enterprise": { + department: "Engineering", + title: "Software Engineer", + }, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({ + department: "Engineering", + title: "Software Engineer", + }); + }); + + it("should extract string array attributes", () => { + const event = { + event: "user.updated", + data: { + raw: { + schemas: ["custom:enterprise"], + "custom:enterprise": { + skills: ["JavaScript", "TypeScript", "React"], + }, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({ + skills: ["JavaScript", "TypeScript", "React"], + }); + }); + + it("should ignore null values", () => { + const event = { + event: "user.created", + data: { + raw: { + schemas: ["custom:enterprise"], + "custom:enterprise": { + department: "Engineering", + title: null, + }, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({ + department: "Engineering", + }); + }); + + it("should ignore an object value as well", () => { + const event = { + event: "user.created", + data: { + raw: { + schemas: ["custom:enterprise"], + "custom:enterprise": { + department: { value: "Engineering" }, + }, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({}); + }); + + it("should handle multiple schemas but ignore duplicates - You can only have department in one namespace right now", () => { + const event = { + event: "user.created", + data: { + raw: { + schemas: ["custom:enterprise", "custom:org"], + "custom:enterprise": { + department: "Engineering", + }, + "custom:org": { + department: "Different Department", // This should be ignored as department already exists + location: "Remote", + }, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({ + department: "Engineering", + location: "Remote", + }); + }); + + it("should ignore non-string schema names", () => { + const event = { + event: "user.created", + data: { + raw: { + schemas: ["custom:enterprise", 123], // Invalid schema + "custom:enterprise": { + department: "Engineering", + }, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({ + department: "Engineering", + }); + }); + + it("should extract from core namespace as well.", () => { + const event = { + event: "user.created", + data: { + raw: { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], + userName: "kush@acme.com", + name: { givenName: "Kush", familyName: "Kush" }, + emails: [{ primary: true, value: "kush@acme.com" }], + displayName: "Kush", + territory: "XANAM", + externalId: "00ulb1kpy4EMATtuS5d7", + groups: [], + active: true, + }, + }, + } as DirectorySyncEvent; + + const result = getAttributesFromScimPayload(event); + expect(result).toEqual({ + userName: "kush@acme.com", + displayName: "Kush", + territory: "XANAM", + externalId: "00ulb1kpy4EMATtuS5d7", + groups: [], + }); + }); +}); diff --git a/packages/features/ee/dsync/lib/getAttributesFromScimPayload.ts b/packages/features/ee/dsync/lib/getAttributesFromScimPayload.ts new file mode 100644 index 0000000000..ebd9ed9c52 --- /dev/null +++ b/packages/features/ee/dsync/lib/getAttributesFromScimPayload.ts @@ -0,0 +1,115 @@ +import type { DirectorySyncEvent } from "@boxyhq/saml-jackson"; + +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; + +const log = logger.getSubLogger({ prefix: ["getAttributesFromScimPayload"] }); + +type ScimUserAttributeName = string; +type ScimUserAttributeValue = string | string[]; +/** + * event.data.raw has this format + * { + * "schemas": [ + * "urn:ietf:params:scim:schemas:core:2.0:User", + * "segment", + * "territory" + * ], + * "userName": "member@samldemo.com", + * "name": { + * "givenName": "Member SAML Demo", + * "familyName": "Member SAML Demo" + * }, + * "emails": [ + * { + * "primary": true, + * "value": "member@samldemo.com" + * } + * ], + * "displayName": "Member SAML Demo", + * "territory": { + * "territory": "NAM" + * }, + * "segment": { + * "segment": "SMB" + * }, + * "externalId": "00ukzk1wrsKZqofit5d7", + * "groups": [], + * "active": true, + * "id": "b36ba9fa-783b-44e6-a770-a652cb9d71ba" + * } + * + * Transforms above to + * { + * "territory": "NAM", + * "segment": "SMB" + * } + */ +function getAttributesFromScimPayload( + event: DirectorySyncEvent +): Record { + const scimUserAttributes: Record = {}; + + if (event.event !== "user.created" && event.event !== "user.updated") { + log.error("getAttributesFromScimPayload", `Unsupported event: ${event.event}`); + return scimUserAttributes; + } + + const raw = event.data.raw; + raw.schemas.forEach((schema: unknown) => { + if (schema === "urn:ietf:params:scim:schemas:core:2.0:User") { + // Core schema has payload in the root + const { schemas: _schemas, ...namespaceData } = raw; + collectAttributes(namespaceData); + return; + } + const namespaceName = schema; + if (typeof namespaceName !== "string") { + log.error( + "getAttributesFromScimPayload", + `Namespace name is not a string ${safeStringify(namespaceName)}` + ); + return; + } + const namespaceData = raw[namespaceName]; + if (!namespaceData) { + log.warn("getAttributesFromScimPayload", `Namespace data for ${namespaceName} is null. Ignoring it.`); + return; + } + + collectAttributes(namespaceData); + }); + + log.debug("Collected attributes", `Attributes: ${safeStringify(scimUserAttributes)}`); + + return scimUserAttributes; + + function collectAttributes(data: Record) { + Object.entries(data).forEach(([customAttributeName, value]) => { + if (!value) { + log.warn( + "getAttributesFromScimPayload", + `Custom attribute ${customAttributeName} is null. Ignoring it.` + ); + return; + } + if (scimUserAttributes[customAttributeName]) { + log.warn( + "getAttributesFromScimPayload", + `Custom attribute ${customAttributeName} already exists. Might be coming from different namespace. Ignoring it.` + ); + return; + } + + // TODO: Support number as well as Attribute support number type + if ( + typeof value === "string" || + (value instanceof Array && value.every((item) => typeof item === "string")) + ) { + scimUserAttributes[customAttributeName] = value; + } + }); + } +} + +export default getAttributesFromScimPayload; diff --git a/packages/features/ee/dsync/lib/handleGroupEvents.ts b/packages/features/ee/dsync/lib/handleGroupEvents.ts index 73f1d127d4..1dcd2981fa 100644 --- a/packages/features/ee/dsync/lib/handleGroupEvents.ts +++ b/packages/features/ee/dsync/lib/handleGroupEvents.ts @@ -1,6 +1,8 @@ import type { DirectorySyncEvent, Group } from "@boxyhq/saml-jackson"; import { createAProfileForAnExistingUser } from "@calcom/lib/createAProfileForAnExistingUser"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums"; @@ -12,7 +14,10 @@ import { import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg"; +const log = logger.getSubLogger({ prefix: ["dsync/handleGroupEvents"] }); + const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: number) => { + log.debug("called", safeStringify(event)); // Find the group name associated with the event const eventData = event.data as Group; diff --git a/packages/features/ee/dsync/lib/handleUserEvents.ts b/packages/features/ee/dsync/lib/handleUserEvents.ts index 64165a95e7..7ab022ff68 100644 --- a/packages/features/ee/dsync/lib/handleUserEvents.ts +++ b/packages/features/ee/dsync/lib/handleUserEvents.ts @@ -1,8 +1,11 @@ import type { DirectorySyncEvent, User } from "@boxyhq/saml-jackson"; import removeUserFromOrg from "@calcom/features/ee/dsync/lib/removeUserFromOrg"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import { UserRepository } from "@calcom/lib/server/repository/user"; +import { assignValueToUserInOrgBulk } from "@calcom/lib/service/attribute/server/assignValueToUser"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; import { getTeamOrThrow } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; @@ -10,11 +13,52 @@ import type { UserWithMembership } from "@calcom/trpc/server/routers/viewer/team import { sendExistingUserTeamInviteEmails } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; +import getAttributesFromScimPayload from "./getAttributesFromScimPayload"; import createUsersAndConnectToOrg from "./users/createUsersAndConnectToOrg"; import dSyncUserSelect from "./users/dSyncUserSelect"; import inviteExistingUserToOrg from "./users/inviteExistingUserToOrg"; +const log = logger.getSubLogger({ prefix: ["handleUserEvents"] }); + +async function syncCustomAttributesToUser({ + event, + userEmail, + org, + directoryId, +}: { + event: DirectorySyncEvent; + userEmail: string; + org: { + id: number; + }; + directoryId: string; +}) { + const user = await prisma.user.findFirst({ + where: { + email: userEmail, + }, + select: dSyncUserSelect, + }); + + if (!user) { + log.error(`User not found in DB ${userEmail}. Skipping custom attributes sync.`); + return; + } + + const customAttributes = getAttributesFromScimPayload(event); + await assignValueToUserInOrgBulk({ + orgId: org.id, + userId: user.id, + attributeLabelToValueMap: customAttributes, + updater: { + dsyncId: directoryId, + }, + }); +} + const handleUserEvents = async (event: DirectorySyncEvent, organizationId: number) => { + log.debug("called", safeStringify(event)); + const directoryId = event.directory_id; const eventData = event.data as User; const userEmail = eventData.email; // Check if user exists in DB @@ -63,6 +107,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe currentUserParentTeamName: org?.parent?.name, orgSlug: org.slug, }); + + await syncCustomAttributesToUser({ + event, + userEmail, + org, + directoryId, + }); } else { // If data.active is false then remove the user from the org await removeUserFromOrg({ @@ -88,6 +139,13 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe teamId: organizationId, isOrg: true, }); + + await syncCustomAttributesToUser({ + event, + userEmail, + org, + directoryId, + }); } }; diff --git a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx index 6c5e72a173..0b4509da2d 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx @@ -5,6 +5,7 @@ import { Controller, useForm, useFieldArray } from "react-hook-form"; import { z } from "zod"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { entries } from "@calcom/prisma/zod-utils"; import { Button, Form, @@ -14,17 +15,26 @@ import { Input, Dialog, ConfirmationDialogContent, + SettingsToggle, } from "@calcom/ui"; -const AttributeSchema = z.object({ +const AttributeFormSchema = z.object({ attrName: z.string().min(1), + isLocked: z.boolean().optional(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), options: z.array( - z.object({ value: z.string(), id: z.string().optional(), assignedUsers: z.number().optional() }) + z.object({ + value: z.string(), + id: z.string().optional(), + assignedUsers: z.number().optional(), + isGroup: z.boolean().optional(), + contains: z.array(z.string()).optional(), + attributeOptionId: z.string().optional(), + }) ), }); -type FormValues = z.infer; +type FormValues = z.infer; const AttributeTypeOptions = [ { value: "TEXT", label: "Text" }, @@ -39,6 +49,75 @@ interface AttributeFormProps { header: React.ReactNode; } +export function getGroupOptionUpdate({ + newGroupOptions, + previousGroupOptions, + subOption, + allOptions, +}: { + newGroupOptions: { label: string; value: string | undefined }[]; + previousGroupOptions: { label: string; value: string | undefined }[]; + subOption: { attributeOptionId?: string }; + allOptions: { attributeOptionId?: string; contains?: string[] }[]; +}) { + const nonGroupOptionId = subOption.attributeOptionId; + if (!nonGroupOptionId) return {}; + // Find diff b/w previouslyChosenGroupOptions and chosenGroupOptions + const removedGroupOptions = previousGroupOptions.filter( + (option) => !newGroupOptions.map((o) => o.value).includes(option.value) + ); + + const optionsUpdate: { [key: number]: string[] } = {}; + + // Update all groupOptions' contains as per the final state. + newGroupOptions + .filter((option): option is { label: string; value: string } => !!option.value) + .forEach((newGroupOption) => { + const { groupOption, index: indexOfGroupOptionInWatchedOptions } = getGroupOptionMatching({ + allOptions, + toMatchWith: newGroupOption, + }); + if (!groupOption) return; + const existingContains = groupOption.contains; + const newContains = [...(existingContains || []), nonGroupOptionId].filter( + (value, index, self) => self.indexOf(value) === index + ); + optionsUpdate[indexOfGroupOptionInWatchedOptions] = newContains; + }); + + // Because certain groupOptions might have been removed, we need to update such groupOptions' contains as well. + removedGroupOptions + .filter((option): option is { label: string; value: string } => !!option.value) + .forEach((removedGroupOption) => { + const { groupOption, index: indexOfGroupOptionInWatchedOptions } = getGroupOptionMatching({ + allOptions, + toMatchWith: removedGroupOption, + }); + if (!groupOption) return; + const existingContains = groupOption.contains || []; + const newContains = existingContains.filter((value) => value !== nonGroupOptionId); + optionsUpdate[indexOfGroupOptionInWatchedOptions] = newContains; + }); + return optionsUpdate; + + function getGroupOptionMatching({ + allOptions, + toMatchWith, + }: { + allOptions: { attributeOptionId?: string; contains?: string[] }[]; + toMatchWith: { value: string }; + }) { + const indexOfGroupOptionInWatchedOptions = allOptions.findIndex( + (option) => option.attributeOptionId === toMatchWith.value + ); + return { + index: indexOfGroupOptionInWatchedOptions, + groupOption: + (allOptions[indexOfGroupOptionInWatchedOptions] as (typeof allOptions)[number] | undefined) ?? null, + }; + } +} + export function AttributeForm({ initialValues, onSubmit, header }: AttributeFormProps) { const { t } = useLocale(); const [deleteOptionDialog, setDeleteOptionDialog] = useState<{ @@ -53,11 +132,25 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm easing: "ease-in-out", }); + // Needed because useFieldArray overrides the id field + const initialValuesEnsuringThatOptionIdIsNotInId = initialValues + ? { + ...initialValues, + options: initialValues.options.map((option) => ({ + ...option, + // Set attributeOptionId equal to id field because 'id' property is used by useFieldArray + attributeOptionId: option.id, + // Also, set id to undefined to make it clear that this id is not used by us. + id: undefined, + })), + } + : undefined; + const form = useForm({ - resolver: zodResolver(AttributeSchema), - defaultValues: initialValues || { + resolver: zodResolver(AttributeFormSchema), + defaultValues: initialValuesEnsuringThatOptionIdIsNotInId || { attrName: "", - options: [{ value: "" }], + options: [{ value: "", isGroup: false }], type: "TEXT", }, }); @@ -67,8 +160,9 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm name: "options", }); + const watchedOptions = form.watch("options"); + const watchedGroupOptions = watchedOptions.filter((field) => field.isGroup); const watchedType = form.watch("type"); - return (
{ onSubmit({ ...values, - options: values.options, + options: values.options.map(({ attributeOptionId, ...option }) => ({ + ...option, + // Set back attributeOptionId in id field + id: attributeOptionId, + })), }); }}> {header} + { + return ( + { + onChange(checked); + }} + /> + ); + }} + />
- -
- {fields.map((field, index) => ( -
- - -
- ))} +
+ +
+ {watchedOptions.map((nonGroupOption, index) => { + const isAGroupOption = nonGroupOption.isGroup; + if (isAGroupOption) return null; + const groupOptionsSelectFieldSelectedValue = watchedGroupOptions + ?.filter( + ({ contains }) => + nonGroupOption.attributeOptionId && + contains?.includes(nonGroupOption.attributeOptionId) + ) + .map((groupOption) => ({ + label: groupOption.value, + value: groupOption.attributeOptionId, + })); + + const groupOptionsSelectFieldOptions = watchedGroupOptions + .map((f) => ({ + label: f.value, + value: f.attributeOptionId, + })) + .filter((option): option is { label: string; value: string } => !!option.value); + + return ( + <> +
+
+ + { + // subOption is the option that is being added to groups + const subOption = nonGroupOption; + const optionsUpdate = getGroupOptionUpdate({ + // Spread makes it non-readonly + newGroupOptions: [...chosenGroupOptions], + previousGroupOptions: groupOptionsSelectFieldSelectedValue, + subOption, + allOptions: watchedOptions, + }); + entries(optionsUpdate).forEach(([index, value]) => { + form.setValue(`options.${index}.contains`, value); + }); + }} + className="min-w-64" + /> +
+
+ + + ); + })} +
+ +
+
+ +
+ {watchedOptions.map((watchedOption, index) => { + const isAGroupOption = watchedOption.isGroup; + if (!isAGroupOption) return null; + return ( + <> +
+
+ +
+
+ + + ); + })} +
+
-
)} <> {t("delete_attribute_description", { - numberOfUsers: fields[deleteOptionDialog.id as number]?.assignedUsers || 0, + numberOfUsers: watchedOptions[deleteOptionDialog.id as number]?.assignedUsers || 0, })} diff --git a/packages/features/ee/organizations/pages/settings/attributes/__tests__/AttributeForm.test.ts b/packages/features/ee/organizations/pages/settings/attributes/__tests__/AttributeForm.test.ts new file mode 100644 index 0000000000..7df9ba7396 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/attributes/__tests__/AttributeForm.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; + +import "../../../../../../../ui/components/test-setup"; +import { getGroupOptionUpdate } from "../AttributesForm"; + +describe("getGroupOptionUpdate", () => { + it("should add non-group option to new group's contains array", () => { + const result = getGroupOptionUpdate({ + newGroupOptions: [{ label: "Group 1", value: "group1" }], + previousGroupOptions: [], + subOption: { attributeOptionId: "option1" }, + allOptions: [{ attributeOptionId: "group1", contains: [] }], + }); + + expect(result).toEqual({ + 0: ["option1"], + }); + }); + + it("should remove non-group option from removed group's contains array", () => { + const result = getGroupOptionUpdate({ + newGroupOptions: [], + previousGroupOptions: [{ label: "Group 1", value: "group1" }], + subOption: { attributeOptionId: "option1" }, + allOptions: [{ attributeOptionId: "group1", contains: ["option1", "option2"] }], + }); + + expect(result).toEqual({ + 0: ["option2"], + }); + }); + + it("should handle multiple group updates simultaneously", () => { + const result = getGroupOptionUpdate({ + newGroupOptions: [ + { label: "Group 1", value: "group1" }, + { label: "Group 2", value: "group2" }, + ], + previousGroupOptions: [{ label: "Group 3", value: "group3" }], + subOption: { attributeOptionId: "option1" }, + allOptions: [ + { attributeOptionId: "group1", contains: ["option2"] }, + { attributeOptionId: "group2", contains: [] }, + { attributeOptionId: "group3", contains: ["option1", "option3"] }, + ], + }); + + expect(result).toEqual({ + 0: ["option2", "option1"], + 1: ["option1"], + 2: ["option3"], + }); + }); + + it("should prevent duplicate entries in contains array", () => { + const result = getGroupOptionUpdate({ + newGroupOptions: [{ label: "Group 1", value: "group1" }], + previousGroupOptions: [], + subOption: { attributeOptionId: "option1" }, + allOptions: [{ attributeOptionId: "group1", contains: ["option1"] }], + }); + + expect(result).toEqual({ + 0: ["option1"], // Should not have duplicate "option1" + }); + }); + + it("should not consider a group option with undefined value", () => { + const result = getGroupOptionUpdate({ + newGroupOptions: [{ label: "Group 1", value: undefined }], + previousGroupOptions: [], + subOption: { attributeOptionId: "option1" }, + allOptions: [{ attributeOptionId: "1", contains: ["option1"] }], + }); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx index 3e361eeff4..cc699b7dc1 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx @@ -43,6 +43,7 @@ function CreateAttributesPage() { mutation.mutate({ name: values.attrName, type: values.type, + isLocked: values.isLocked, options: Array.from(uniqueAttributes).map((value) => ({ value })), }); }} diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx index 64136c9c46..f305818c9e 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useParams } from "next/navigation"; +import { useParams } from "next/navigation"; import React from "react"; import { useFormContext } from "react-hook-form"; import { z } from "zod"; @@ -22,7 +22,6 @@ const CreateAttributeSchema = z.object({ type FormValues = z.infer; function CreateAttributesPage() { - const router = useRouter(); const utils = trpc.useUtils(); const { t } = useLocale(); // Get the attribute id from the url @@ -38,7 +37,6 @@ function CreateAttributesPage() { id, }); utils.viewer.attributes.list.invalidate(); - router.push("/settings/organizations/attributes"); }, onError: (err) => { showToast(err.message, "error"); @@ -54,14 +52,15 @@ function CreateAttributesPage() { attrName: attribute.data.name, type: attribute.data.type, options: attribute.data.options, + isLocked: attribute.data.isLocked, }} header={} onSubmit={(values) => { + const { attrName, ...rest } = values; mutation.mutate({ + ...rest, + name: attrName, attributeId: id, - name: values.attrName, - type: values.type, - options: values.options, }); }} /> diff --git a/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts b/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts index 98116c4c72..65e3976129 100644 --- a/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts +++ b/packages/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder.ts @@ -1,7 +1,7 @@ import type { FormResponse, Fields } from "@calcom/app-store/routing-forms/types/types"; import { zodRoutes } from "@calcom/app-store/routing-forms/zod"; -import { getUsersAttributes } from "@calcom/lib/raqb/getAttributes"; import { acrossQueryValueCompatiblity } from "@calcom/lib/raqb/raqbUtils"; +import { getUsersAttributes } from "@calcom/lib/service/attribute/server/getAttributes"; import prisma from "@calcom/prisma"; import { AssignmentReasonEnum } from "@calcom/prisma/enums"; diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx index 9cdb09d5e9..f8812fd16f 100644 --- a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx +++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx @@ -283,6 +283,8 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) { attributeId: value.attributeId, value: value.value, slug: value.slug, + contains: value.contains, + isGroup: value.isGroup, })); newAttributes.push(...newAttributeValues); } else { @@ -292,6 +294,8 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) { attributeId: foundAttributeInCache?.id ?? "-1", value: selectedAttributeOptions[0], slug: slugify(selectedAttributeOptions[0]), + contains: [], + isGroup: false, }); } diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx index c4455969c1..3f40dfbf6b 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx @@ -40,7 +40,15 @@ const stringOrNumber = z.string().or(z.number()); const attributeSchema = z.object({ id: z.string(), - options: z.array(z.object({ label: z.string().optional(), value: stringOrNumber.optional() })).optional(), + options: z + .array( + z.object({ + label: z.string().optional(), + value: stringOrNumber.optional(), + createdByDSyncId: z.string().nullable().optional(), + }) + ) + .optional(), value: stringOrNumber.optional(), }); @@ -228,10 +236,10 @@ export function EditForm({ ); } -type AttributeType = z.infer; +type Attribute = z.infer; type DefaultValueType = { - [key: `attributes.${number}`]: AttributeType; + [key: `attributes.${number}`]: Attribute; }; function AttributesList(props: { selectedUserId: number }) { @@ -267,12 +275,22 @@ function AttributesList(props: { selectedUserId: number }) { } else if (attr.type === "MULTI_SELECT") { acc[key] = { id: attr.id, - options: attr.options.map((option) => ({ label: option.value, value: option.id })), + options: attr.options.map((option) => ({ + label: option.value, + value: option.id, + createdByDSyncId: option.createdByDSyncId, + })), }; } else if (attr.type === "SINGLE_SELECT") { acc[key] = { id: attr.id, - options: [{ label: attr.options[0]?.value, value: attr.options[0]?.id }], + options: [ + { + label: attr.options[0]?.value, + value: attr.options[0]?.id, + createdByDSyncId: attr.options[0]?.createdByDSyncId ?? null, + }, + ], }; } else { acc[key] = { @@ -302,7 +320,8 @@ function AttributesList(props: { selectedUserId: number }) { control={control} key={attr.id} defaultValue={defaultValues[`attributes.${index}`]} - render={({ field }) => { + render={({ field: { value, ...field } }) => { + const fieldValue = value as Attribute | undefined | null; return (
{["TEXT", "NUMBER"].includes(attr.type) && ( @@ -312,7 +331,7 @@ function AttributesList(props: { selectedUserId: number }) { labelClassName="text-emphasis mb-2 block text-sm font-medium leading-none" label={attr.name} type={attr.type === "TEXT" ? "text" : "number"} - value={field.value?.value || ""} + value={fieldValue?.value || ""} onChange={(e) => { field.onChange({ id: attr.id, @@ -323,6 +342,7 @@ function AttributesList(props: { selectedUserId: number }) { )} {["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && ( { - if (attr.type === "MULTI_SELECT") { - field.onChange({ - id: attr.id, - options: value.map((v: any) => ({ label: v.label, value: v.value })), - }); - } else { - field.onChange({ - id: attr.id, - options: [{ label: value.label, value: value.value }], - }); - } + if (!value) return; + const valueAsArray = value instanceof Array ? value : [value]; + + const updatedOptions = + attr.type === "MULTI_SELECT" + ? valueAsArray.map((v) => ({ label: v.label, value: v.value })) + : [{ label: valueAsArray[0].label, value: valueAsArray[0].value }]; + + field.onChange({ + id: attr.id, + // It is also ensured in the backend that the options not owned by cal.com are not removed + options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({ + earlierOptions: fieldValue?.options || [], + updatedOptions, + }), + }); }} /> )} @@ -356,3 +381,31 @@ function AttributesList(props: { selectedUserId: number }) {
); } + +/** + * Ensures that options that are not owned by cal.com are not removed + * Such options are created by dsync and removed only through corresponding dsync + */ +function getOptionsEnsuringNotOwnedByCalcomNotRemoved< + // Before assigning this option it can't have createdByDSyncId set + TOptionToChoose extends { value: string | number | undefined }, + // Already set option can have createdByDSyncId set + TOptionAlreadySet extends { + value?: string | number | undefined; + createdByDSyncId?: string | null | undefined; + } +>({ + earlierOptions, + updatedOptions, +}: { + earlierOptions: TOptionAlreadySet[]; + updatedOptions: TOptionToChoose[]; +}) { + const optionsNotOwnedByCalcom = earlierOptions.filter((option) => !!option.createdByDSyncId); + + const newUniqueOptionsPlusNotOwnedByCalcom = [...optionsNotOwnedByCalcom, ...updatedOptions].filter( + (option, index, self) => index === self.findIndex((o) => o.value === option.value && o.value) + ); + + return newUniqueOptionsPlusNotOwnedByCalcom; +} diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 3f139745aa..f25b3450cc 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -1,13 +1,7 @@ "use client"; import { keepPreviousData } from "@tanstack/react-query"; -import { - getCoreRowModel, - getFilteredRowModel, - getSortedRowModel, - useReactTable, - type ColumnDef, -} from "@tanstack/react-table"; +import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; import { useSession } from "next-auth/react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { useMemo, useReducer, useRef, useState } from "react"; @@ -185,11 +179,14 @@ export function UserListTable() { if (attributeValues.length === 0) return null; return (
- {attributeValues.map((attributeValue, index) => ( - - {attributeValue.value} - - ))} + {attributeValues.map((attributeValue, index) => { + const isAGroupOption = attributeValue.contains?.length > 0; + return ( + + {attributeValue.value} + + ); + })}
); }, @@ -405,8 +402,6 @@ export function UserListTable() { rowSelection, }, getCoreRowModel: getCoreRowModel(), - // TODO(SEAN): We need to move filter state to the server so we can fetch more data when the filters change if theyre not in client cache - getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), onRowSelectionChange: setRowSelection, getRowId: (row) => `${row.id}`, diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts index ef64f8c16c..37291a9c8b 100644 --- a/packages/features/users/components/UserTable/types.ts +++ b/packages/features/users/components/UserTable/types.ts @@ -21,6 +21,7 @@ export interface UserTableUser { attributeId: string; value: string; slug: string; + contains: string[]; }[]; } diff --git a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts index 91d29c1607..638f9ed798 100644 --- a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts @@ -7,14 +7,13 @@ import { findTeamMembersMatchingAttributeLogic, TroubleshooterCase, } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic"; +// import { EmailField } from "@calcom/ui"; +import * as getAttributesModule from "@calcom/lib/service/attribute/server/getAttributes"; import type { AttributeType } from "@calcom/prisma/enums"; import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes"; import type { AttributesQueryValue, FormFieldsQueryValue } from "@calcom/routing-forms/types/types"; -// import { EmailField } from "@calcom/ui"; -import * as getAttributesModule from "./getAttributes"; - -vi.mock("./getAttributes"); +vi.mock("@calcom/lib/service/attribute/server/getAttributes"); vi.mock("../../components/react-awesome-query-builder/widgets", () => ({ default: {}, })); @@ -32,18 +31,27 @@ function mockAttributesScenario({ }) { vi.mocked(getAttributesModule.getAttributesForTeam).mockResolvedValue(attributes); vi.mocked(getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute).mockResolvedValue( - teamMembersWithAttributeOptionValuePerAttribute.map((member) => ({ - ...member, - attributes: Object.fromEntries( - Object.entries(member.attributes).map(([attributeId, value]) => { - return [ - attributeId, - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - { value, type: attributes.find((attribute) => attribute.id === attributeId)?.type! }, - ]; - }) - ), - })) + teamMembersWithAttributeOptionValuePerAttribute.map((member) => { + return { + ...member, + attributes: Object.fromEntries( + Object.entries(member.attributes).map(([attributeId, value]) => { + return [ + attributeId, + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + { + attributeOption: + value instanceof Array + ? value.map((value) => ({ value, isGroup: false, contains: [] })) + : { value, isGroup: false, contains: [] }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + type: attributes.find((attribute) => attribute.id === attributeId)!.type, + }, + ]; + }) + ), + }; + }) ); } @@ -639,8 +647,6 @@ describe("findTeamMembersMatchingAttributeLogic", () => { attributesQueryValue: failingAttributesQueryValue, fallbackAttributesQueryValue: failingAttributesQueryValue, teamId: 1, - attributesQueryValue: failingAttributesQueryValue, - fallbackAttributesQueryValue: failingAttributesQueryValue, }); expect(checkedFallback).toEqual(true); diff --git a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts index 7eb4b993c3..3ce8541441 100644 --- a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.ts @@ -4,12 +4,16 @@ import type { Config } from "react-awesome-query-builder/lib"; import { Utils as QbUtils } from "react-awesome-query-builder/lib"; import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types"; +import { + getTeamMembersWithAttributeOptionValuePerAttribute, + getAttributesForTeam, +} from "@calcom/lib/service/attribute/server/getAttributes"; +import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes"; import { RaqbLogicResult } from "./evaluateRaqbLogic"; -import { getTeamMembersWithAttributeOptionValuePerAttribute, getAttributesForTeam } from "./getAttributes"; import jsonLogic from "./jsonLogic"; import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils"; -import type { Attribute, AttributesQueryValue } from "./types"; +import type { AttributesQueryValue } from "./types"; const { getAttributesData: getAttributes, diff --git a/packages/lib/raqb/getAttributes.ts b/packages/lib/raqb/getAttributes.ts deleted file mode 100644 index 01542a528f..0000000000 --- a/packages/lib/raqb/getAttributes.ts +++ /dev/null @@ -1,155 +0,0 @@ -// TODO: Queries in this file are not optimized. Need to optimize them. -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import prisma from "@calcom/prisma"; - -import type { Attribute } from "./types"; - -async function getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }: { teamId: number }) { - const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); - - const whereClauseForAttributesAssignedToMembersOfTeam = { - member: { - user: { - teams: { - some: { - teamId, - }, - }, - }, - }, - }; - - log.debug( - safeStringify({ - teamId, - whereClauseForAttributesAssignedToMembersOfTeam, - }) - ); - - const attributesToUser = await prisma.attributeToUser.findMany({ - where: whereClauseForAttributesAssignedToMembersOfTeam, - select: { - member: { - select: { - userId: true, - }, - }, - attributeOption: { - select: { - id: true, - value: true, - slug: true, - attribute: { - select: { id: true, name: true, type: true, slug: true }, - }, - }, - }, - }, - }); - - log.debug("Returned attributesToUser", safeStringify({ attributesToUser })); - return attributesToUser; -} - -async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) { - const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); - - const whereClauseForAttributesAssignedToMembersOfTeam = { - options: { - some: { - assignedUsers: { - some: { - member: { - userId, - user: { - teams: { - some: { - teamId, - }, - }, - }, - }, - }, - }, - }, - }, - }; - - log.debug( - safeStringify({ - teamId, - whereClauseForAttributesAssignedToMembersOfTeam, - }) - ); - - const attributesToUser = await prisma.attribute.findMany({ - where: whereClauseForAttributesAssignedToMembersOfTeam, - select: { - id: true, - name: true, - type: true, - options: { - select: { - id: true, - value: true, - slug: true, - }, - }, - slug: true, - }, - }); - return attributesToUser; -} - -export async function getAttributesForTeam({ teamId }: { teamId: number }) { - const attributes = await getAttributesAssignedToMembersOfTeam({ teamId }); - return attributes satisfies Attribute[]; -} - -type AttributeId = string; -type AttributeOptionValueWithType = { - type: Attribute["type"]; - value: string | string[]; -}; - -type UserId = number; - -export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) { - const attributesToUser = await getAttributeToUserWithMembershipAndAttributesForTeam({ teamId }); - - const teamMembers = attributesToUser.reduce((acc, attributeToUser) => { - const { userId } = attributeToUser.member; - const { attribute, value } = attributeToUser.attributeOption; - - if (!acc[userId]) { - acc[userId] = { userId, attributes: {} }; - } - - const attributes = acc[userId].attributes; - const attributeValue = attributes[attribute.id]?.value; - if (attributeValue instanceof Array) { - // Value already exists, so push to it - attributeValue.push(value); - } else if (attributeValue) { - // Value already exists, so push to it and also make it an array before pushing - attributes[attribute.id] = { - type: attribute.type, - value: [attributeValue, value], - }; - } else { - // Set the first value - attributes[attribute.id] = { - type: attribute.type, - value, - }; - } - return acc; - }, {} as Record }>); - - return Object.values(teamMembers); -} - -export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) { - return await getAttributesAssignedToMembersOfTeam({ teamId, userId }); -} diff --git a/packages/lib/raqb/raqbUtils.test.ts b/packages/lib/raqb/raqbUtils.test.ts new file mode 100644 index 0000000000..d6ecb5c9cf --- /dev/null +++ b/packages/lib/raqb/raqbUtils.test.ts @@ -0,0 +1,90 @@ +import { expect, describe, it } from "vitest"; + +import { getValueOfAttributeOption } from "./raqbUtils"; + +describe("getValueOfAttributeOption", () => { + it("should return non-array value for non-array input - It is a requirement for RAQB to not unnecessarily transform single value to an array of one item", () => { + const input = { value: "option1", isGroup: false, contains: [] }; + const result = getValueOfAttributeOption(input); + expect(result).toEqual("option1"); + }); + + it("should return flat array of values for simple options", () => { + const input = [ + { value: "option1", isGroup: false, contains: [] }, + { value: "option2", isGroup: false, contains: [] }, + ]; + + const result = getValueOfAttributeOption(input); + expect(result).toEqual(["option1", "option2"]); + }); + + it("should flatten nested options from contains array", () => { + const input = [ + { + value: "group1", + isGroup: true, + contains: [ + { value: "suboption1", id: "opt-1", slug: "option-1" }, + { value: "suboption2", id: "opt-2", slug: "option-2" }, + ], + }, + ]; + + const result = getValueOfAttributeOption(input); + expect(result).toEqual(["suboption1", "suboption2"]); + }); + + it("should handle mix of simple and nested options", () => { + const input = [ + { value: "option1", isGroup: false, contains: [] }, + { + value: "group1", + isGroup: true, + contains: [ + { value: "suboption1", id: "opt-1", slug: "option-1" }, + { value: "suboption2", id: "opt-2", slug: "option-2" }, + ], + }, + { value: "option2", isGroup: false, contains: [] }, + ]; + + const result = getValueOfAttributeOption(input); + expect(result).toEqual(["option1", "suboption1", "suboption2", "option2"]); + }); + + it("should remove duplicate values", () => { + const input = [ + { value: "option1", isGroup: false, contains: [] }, + { + value: "group1", + isGroup: true, + contains: [ + { value: "option1", id: "opt-1", slug: "option-1" }, + { value: "suboption2", id: "opt-2", slug: "option-2" }, + ], + }, + ]; + + const result = getValueOfAttributeOption(input); + expect(result).toEqual(["option1", "suboption2"]); + }); + + it("should handle empty input array", () => { + const input: { + value: string; + isGroup: boolean; + contains: { value: string; id: string; slug: string }[]; + }[] = []; + + const result = getValueOfAttributeOption(input); + expect(result).toEqual([]); + }); + + it("should still use contains if contains is empty and isGroup=true", () => { + const input = [{ value: "group1", isGroup: true, contains: [] }]; + + const result = getValueOfAttributeOption(input); + expect(result).toEqual([]); + }); +}); diff --git a/packages/lib/raqb/raqbUtils.ts b/packages/lib/raqb/raqbUtils.ts index 107888a457..5b72019717 100644 --- a/packages/lib/raqb/raqbUtils.ts +++ b/packages/lib/raqb/raqbUtils.ts @@ -6,8 +6,13 @@ import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-fo import type { LocalRoute } from "@calcom/app-store/routing-forms/types/types"; import logger from "@calcom/lib/logger"; import type { dynamicFieldValueOperands, dynamicFieldValueOperandsResponse } from "@calcom/lib/raqb/types"; -import type { Attribute, AttributesQueryValue } from "@calcom/lib/raqb/types"; +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; import { safeStringify } from "@calcom/lib/safeStringify"; +import type { + AttributeOptionValueWithType, + AttributeOptionValue, + Attribute, +} from "@calcom/lib/service/attribute/server/getAttributes"; import { AttributeType } from "@calcom/prisma/enums"; const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/raqbUtils"] }); @@ -194,31 +199,54 @@ const replaceFieldTemplateVariableWithOptionLabel = ({ }); }; +export function getValueOfAttributeOption( + attributeOptions: + | Pick + | Pick[] +) { + if (!(attributeOptions instanceof Array)) { + return transformAttributeOption(attributeOptions); + } + return attributeOptions + .map(transformAttributeOption) + .flat() + .filter((value, index, self) => self.indexOf(value) === index); + + function transformAttributeOption( + attributeOption: Pick + ) { + if (attributeOption.isGroup) { + const subOptions = attributeOption.contains.map((option) => option.value); + console.log("A group option found. Using all its sub-options instead", safeStringify(subOptions)); + return subOptions; + } + return attributeOption.value; + } +} + function getAttributesData({ attributesData, }: { - attributesData: Record< - string, - { - value: string | string[]; - type: Attribute["type"]; - } - >; + attributesData: Record; attributesQueryValue: NonNullable; }) { - return Object.entries(attributesData).reduce((acc, [attributeId, { value, type: attributeType }]) => { - const compatibleValueForAttributeAndFormFieldMatching = caseInsensitive(value); + return Object.entries(attributesData).reduce( + (acc, [attributeId, { type: attributeType, attributeOption }]) => { + const compatibleValueForAttributeAndFormFieldMatching = caseInsensitive( + getValueOfAttributeOption(attributeOption) + ); - // Right now we can't trust ensureAttributeValueToBeOfRaqbFieldValueType to give us the correct value - acc[attributeId] = - // multiselect attribute's value must be an array as all the operators multiselect_some_in, multiselect_all_in and their respective not operators expect an array - // If we add an operator that doesn't expect an array, we need to somehow make it operator based. - attributeType === AttributeType.MULTI_SELECT - ? ensureArray(compatibleValueForAttributeAndFormFieldMatching) - : compatibleValueForAttributeAndFormFieldMatching; + acc[attributeId] = + // multiselect attribute's value must be an array as all the operators multiselect_some_in, multiselect_all_in and their respective not operators expect an array + // If we add an operator that doesn't expect an array, we need to somehow make it operator based. + attributeType === AttributeType.MULTI_SELECT + ? ensureArray(compatibleValueForAttributeAndFormFieldMatching) + : compatibleValueForAttributeAndFormFieldMatching; - return acc; - }, {} as Record); + return acc; + }, + {} as Record + ); } /** Gets the attributes that were used to generate the chosen route and their values */ function getAttributesQueryValue({ diff --git a/packages/lib/raqb/types.d.ts b/packages/lib/raqb/types.d.ts index f0f1db8fa7..a10b0c12f1 100644 --- a/packages/lib/raqb/types.d.ts +++ b/packages/lib/raqb/types.d.ts @@ -1,7 +1,5 @@ import type { z } from "zod"; -import type { AttributeType } from "@calcom/prisma/client"; - import type { raqbChildSchema, raqbQueryValueSchema, zodAttributesQueryValue } from "./zod"; export type RaqbChild = z.infer; @@ -33,15 +31,3 @@ export type dynamicFieldValueOperands = { }[]; response: dynamicFieldValueOperandsResponse; }; - -export type Attribute = { - name: string; - slug: string; - type: AttributeType; - id: string; - options: { - id: string; - value: string; - slug: string; - }[]; -}; diff --git a/packages/lib/server/repository/attribute.ts b/packages/lib/server/repository/attribute.ts new file mode 100644 index 0000000000..0497aeb0b8 --- /dev/null +++ b/packages/lib/server/repository/attribute.ts @@ -0,0 +1,27 @@ +import prisma from "@calcom/prisma"; + +export class AttributeRepository { + static async findManyByNamesAndOrgIdIncludeOptions({ + attributeNames, + orgId, + }: { + attributeNames: string[]; + orgId: number; + }) { + return prisma.attribute.findMany({ + where: { + name: { in: attributeNames, mode: "insensitive" }, + teamId: orgId, + }, + include: { + options: { + select: { + id: true, + value: true, + slug: true, + }, + }, + }, + }); + } +} diff --git a/packages/lib/server/repository/attributeOption.ts b/packages/lib/server/repository/attributeOption.ts new file mode 100644 index 0000000000..00b5879e9b --- /dev/null +++ b/packages/lib/server/repository/attributeOption.ts @@ -0,0 +1,31 @@ +import type { Prisma } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +export class AttributeOptionRepository { + static async findMany({ orgId }: { orgId: number }) { + return prisma.attributeOption.findMany({ + where: { + attribute: { + teamId: orgId, + }, + }, + select: { + id: true, + value: true, + slug: true, + attributeId: true, + }, + }); + } + + static async createMany({ createManyInput }: { createManyInput: Prisma.AttributeOptionCreateManyInput[] }) { + const { count } = await prisma.attributeOption.createMany({ + data: createManyInput, + }); + + return { + count, + }; + } +} diff --git a/packages/lib/server/repository/attributeToUser.ts b/packages/lib/server/repository/attributeToUser.ts new file mode 100644 index 0000000000..12a4e272f2 --- /dev/null +++ b/packages/lib/server/repository/attributeToUser.ts @@ -0,0 +1,31 @@ +import type { Prisma } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +export class AttributeToUserRepository { + static async createManySkipDuplicates(data: Prisma.AttributeToUserCreateManyInput[]) { + return await prisma.attributeToUser.createMany({ data, skipDuplicates: true }); + } + + static async deleteMany(where: Prisma.AttributeToUserWhereInput) { + if (Object.keys(where).length === 0) { + throw new Error("Empty where clause provided to deleteMany. Potential data loss risk."); + } + return await prisma.attributeToUser.deleteMany({ where }); + } + + static async findManyIncludeAttribute(where: Prisma.AttributeToUserWhereInput) { + return await prisma.attributeToUser.findMany({ + where, + include: { + attributeOption: { + select: { + attribute: true, + value: true, + slug: true, + }, + }, + }, + }); + } +} diff --git a/packages/lib/service/TODO.md b/packages/lib/service/TODO.md new file mode 100644 index 0000000000..33a1970c4a --- /dev/null +++ b/packages/lib/service/TODO.md @@ -0,0 +1,39 @@ +## BUGS +- Test Attribute Edit. It seems to be doing weird things with the ids. + +## TODO +- [x] Sync from Okta to user could be in core namespace urn:ietf:params:scim:schemas:core:2.0:User and we might need to ask for the namespace to sync from. +- [x] Deleting a sub-option, should remove it from option-group? +- [x] Locked attribute are free to have its options completely reset by SCIM +- [x] Unlocked attribute shouldn't get updates from SCIM but in case they still do, we follow this strategy + - If attribute from SCIM, doesn't exist on user, we allow it to be freely set on the user + - If attribute from SCIM, exists on user + - [x] It is a single select or a non-enum attribute, we don't let it be overriden and completely ignore the SCIM update for that attribute + - [x] It is a multi select, we remove the options that were created from SCIM and add the new options from SCIM. AttributeToUser.createdByDSyncId is set to SCIM directorySync id for those options. It allows both SCIM set and User set options to be assigned to the User. + - [x] The options created by SCIM aren't deletable from Cal.com UI. User can only remove options created by Cal.com i.e. where AttributeToUser.createdByDSyncId is null. +- Restrict setting values + - [x] Allow setting any value for non enum attributes(i.e. non-dropdown attributes) + - [x] Restrict setting values for enum attributes(i.e. dropdown attributes) +- AttributeAssignment within App + - [x] For locked field, shouldn't be able to delete an assignment or add a new assignment. + - [x] For unlocked field, should be able to delete the assignments owned by cal.com users only. Should be able to add new assignments for any user. +- [x] Support array update +- [x] Ensure the already existing assignment(with same value) isn't recreated. To ensure weights aren't reset. + - [x] Locked attributes + - [x] Unlocked attributes + +## Test +- Okta to Cal.com + 1. Add a user with attribute + 2. Simular a failure in adding the user + - Failure could be in user creation or attribute assignment creation + 3. Remove the user and Re-add the user and see it is correctly synced. + - [x] Try removing a locked attribute assignment from a user. + + + +## Documentation +Okta stuff +- Removing the assignment of a user from the app, doesn't remove the user from Cal.com + - I observed that there were only GET request from Okta and no POST request. + - As per https://boxyhq.com/docs/directory-sync/events Okta doesn't even send an event for deleting a user. \ No newline at end of file diff --git a/packages/lib/service/attribute/server/assignValueToUser.test.ts b/packages/lib/service/attribute/server/assignValueToUser.test.ts new file mode 100644 index 0000000000..65c0ba4d50 --- /dev/null +++ b/packages/lib/service/attribute/server/assignValueToUser.test.ts @@ -0,0 +1,1042 @@ +import prismock from "../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import prisma from "@calcom/prisma"; +import type { AttributeOption } from "@calcom/prisma/client"; +import { AttributeType, MembershipRole } from "@calcom/prisma/enums"; + +import { assignValueToUserInOrgBulk, buildPrismaQueriesForAttributeOptionToUser } from "./assignValueToUser"; + +function buildMockAttribute(data: { + teamId: number; + id: string; + type: AttributeType; + name: string; + slug: string; + isLocked: boolean; +}) { + return { + usersCanEditRelation: true, + isWeightsEnabled: false, + createdAt: new Date(), + updatedAt: new Date(), + enabled: true, + ...data, + }; +} + +async function createMockAttribute({ + options, + ...attributeData +}: Parameters[0] & { + options: Pick[]; +}) { + const attribute = await prismock.attribute.create({ data: buildMockAttribute(attributeData) }); + const attributeOptions = await prismock.attributeOption.createMany({ + data: options.map((option) => ({ + ...option, + attributeId: attribute.id, + })), + }); + return { + attribute, + attributeOptions, + }; +} + +async function createMockUserWithMembership({ orgId }: { orgId: number }) { + const user = await prismock.user.create({ + data: { + name: "Test User", + email: "test@test.com", + }, + }); + const membership = await prismock.membership.create({ + data: { + role: MembershipRole.MEMBER, + disableImpersonation: false, + accepted: true, + teamId: orgId, + userId: user.id, + }, + }); + return { + user: user, + membership: membership, + }; +} + +async function createMockDSyncData({ + directoryId, + tenant, + organizationId, +}: { + directoryId: string; + tenant: string; + organizationId: number; +}) { + return prismock.dSyncData.create({ + data: { + directoryId, + tenant, + organizationId, + }, + }); +} + +async function createMockAssignment({ + membershipId, + attributeOptionId, + dsyncId, +}: { + membershipId: number; + attributeOptionId: string; + orgId: number; + dsyncId: string | null; +}) { + let dsync; + if (dsyncId) { + dsync = await prismock.dSyncData.findUnique({ + where: { + id: dsyncId as unknown as number, + }, + }); + } + + const attributeToUser = await prismock.attributeToUser.create({ + data: { + memberId: membershipId, + attributeOptionId, + createdByDSyncId: dsync ? (dsync.id as unknown as string) : null, + }, + }); + + return { + attributeToUser, + dsync, + }; +} + +describe("addValueForMember", () => { + const orgId = 1001; + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Misc", () => { + let dsyncDoingAssignment_id: string; + beforeEach(async () => { + const dsync = await createMockDSyncData({ + directoryId: "1", + tenant: "testorg-000", + organizationId: orgId, + }); + dsyncDoingAssignment_id = dsync.id as unknown as string; + }); + + it("should correctly identify attribute and attribute options by name and set them", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.SINGLE_SELECT, + isLocked: true, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "Sales", + }, + updater: { + userId: 999, + }, + }); + + const createdAttributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(createdAttributeToUsers).toHaveLength(1); + expect(createdAttributeToUsers[0].attributeOptionId).toEqual("102"); + + // Do another update. This should reset the options assigned to the user and add just this one + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "engineering", + }, + updater: { + userId: 999, + }, + }); + + const createdAttributeToUsers2 = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(createdAttributeToUsers2).toHaveLength(1); + expect(createdAttributeToUsers2[0].attributeOptionId).toEqual("101"); + }); + + it("should allow setting an array of strings as a value for a multi-select attribute", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.MULTI_SELECT, + isLocked: true, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: ["marketing", "engineering", "unknown"], + }, + // Setting value using the same dsyncId that was used to create the option in SCIM Pool + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(attributeToUsers).toHaveLength(2); + expect(attributeToUsers[0].attributeOptionId).toEqual("103"); + expect(attributeToUsers[1].attributeOptionId).toEqual("101"); + }); + }); + + describe("Locked Attribute(Source of Truth - Dsync)", () => { + const isLocked = true; + describe("Update from SCIM", async () => { + let dsyncDoingAssignment_id: string; + beforeEach(async () => { + const dsync = await createMockDSyncData({ + directoryId: "1", + tenant: "testorg-000", + organizationId: orgId, + }); + dsyncDoingAssignment_id = dsync.id as unknown as string; + }); + + it("should reset all the options(Multi-Select) in all the SCIM Pools and Cal.com User Pool", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.MULTI_SELECT, + isLocked, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + // In SCIM dsync-1 Pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Engineering + attributeOptionId: "101", + dsyncId: dsyncDoingAssignment_id, + }); + + const dsync2 = await createMockDSyncData({ + directoryId: "2", + tenant: "testorg-000", + organizationId: orgId, + }); + + // In SCIM dsync-2 Pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Engineering + attributeOptionId: "101", + dsyncId: dsync2!.id as unknown as string, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "marketing", + }, + // Setting value using the same dsyncId that was used to create the option in SCIM Pool + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(attributeToUsers).toHaveLength(1); + // Cal.com User Pool - Intact + expect(attributeToUsers[0].attributeOptionId).toEqual("103"); + }); + + it("should allow setting an array of strings as a value for a multi-select attribute", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.MULTI_SELECT, + isLocked, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + // In SCIM dsync-1 Pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Engineering + attributeOptionId: "101", + dsyncId: dsyncDoingAssignment_id, + }); + + const dsync2 = await createMockDSyncData({ + directoryId: "2", + tenant: "testorg-000", + organizationId: orgId, + }); + + // In SCIM dsync-2 Pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Engineering + attributeOptionId: "101", + dsyncId: dsync2!.id as unknown as string, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: ["marketing", "engineering"], + }, + // Setting value using the same dsyncId that was used to create the option in SCIM Pool + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(attributeToUsers).toHaveLength(2); + expect(attributeToUsers[0].attributeOptionId).toEqual("103"); + expect(attributeToUsers[1].attributeOptionId).toEqual("101"); + }); + + it("should not recreate an assignment that already exists", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.SINGLE_SELECT, + isLocked, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + const beforeAssignments = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "Sales", + }, + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const afterAssignments = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(afterAssignments).toHaveLength(1); + expect(afterAssignments[0].attributeOptionId).toEqual("102"); + expect(beforeAssignments[0].id).toEqual(afterAssignments[0].id); + expect(beforeAssignments[0].createdByDSyncId).toEqual(afterAssignments[0].createdByDSyncId); + expect(afterAssignments[0].updatedByDSyncId).toEqual(dsyncDoingAssignment_id); + }); + + it("should be able to set a value for TEXT(non-enum) attribute that already has an option set by Cal.com user", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.TEXT, + isLocked, + options: [{ id: "102", value: "sales", slug: "sales-slug" }], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + // "Sales" Option set by cal.com user + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "not-already-an-option", + }, + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + select: { + attributeOption: { + select: { + id: true, + value: true, + }, + }, + }, + }); + + expect(attributeToUsers).toHaveLength(1); + expect(attributeToUsers[0].attributeOption.value).toEqual("not-already-an-option"); + }); + + it.skip("should only delete the assignments for the user that is being pushed", async () => { + // TODO + }); + }); + }); + + describe("Unlocked Attribute", () => { + const isLocked = false; + describe("Update from SCIM", () => { + let dsyncDoingAssignment_id: string; + beforeEach(async () => { + const dsync = await createMockDSyncData({ + directoryId: "1", + tenant: "testorg-000", + organizationId: orgId, + }); + dsyncDoingAssignment_id = dsync.id as unknown as string; + }); + + it("should reset the options(Multi-Select) in the SCIM Pool only and keep Cal.com User Pool options intact when assignValueToUserInOrgBulk is from SCIM", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.MULTI_SELECT, + isLocked, + options: [ + { id: "scim-101", value: "engineering", slug: "engineering-slug" }, + { id: "calcom-102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "calcom-102", + dsyncId: null, + }); + + // In SCIM Pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Engineering + attributeOptionId: "scim-101", + dsyncId: dsyncDoingAssignment_id, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "marketing", + }, + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(attributeToUsers).toHaveLength(2); + // Cal.com User Pool - Intact + expect(attributeToUsers[0].attributeOptionId).toEqual("calcom-102"); + // SCIM Pool - Previous value removed + expect(attributeToUsers[1].attributeOptionId).toEqual("103"); + }); + + it("should not be able to set a value for single-select attribute that already has an option set by Cal.com user", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.SINGLE_SELECT, + isLocked, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + // "Sales" Option set by cal.com user + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + const dsync = await createMockDSyncData({ + directoryId: "1", + tenant: "testorg-000", + organizationId: orgId, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "engineering", + }, + updater: { + dsyncId: dsync.id as unknown as string, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + select: { + attributeOption: { + select: { + id: true, + value: true, + }, + }, + }, + }); + + expect(attributeToUsers).toHaveLength(1); + expect(attributeToUsers[0].attributeOption.value).toEqual("sales"); + expect(attributeToUsers[0].attributeOption.id).toEqual("102"); + }); + + it("should not be able to set a value for TEXT(non-enum) attribute that already has an option set by Cal.com user", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.TEXT, + isLocked, + options: [{ id: "102", value: "sales", slug: "sales-slug" }], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + // "Sales" Option set by cal.com user + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + const dsync = await createMockDSyncData({ + directoryId: "1", + tenant: "testorg-000", + organizationId: orgId, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "not-already-an-option", + }, + updater: { + dsyncId: dsync.id as unknown as string, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + select: { + attributeOption: { + select: { + id: true, + value: true, + }, + }, + }, + }); + + expect(attributeToUsers).toHaveLength(1); + expect(attributeToUsers[0].attributeOption.value).toEqual("sales"); + expect(attributeToUsers[0].attributeOption.id).toEqual("102"); + }); + + it("should not recreate an assignment that already exists", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.SINGLE_SELECT, + isLocked, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + const beforeAssignments = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "Sales", + }, + updater: { + dsyncId: dsyncDoingAssignment_id, + }, + }); + + const afterAssignments = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(afterAssignments).toHaveLength(1); + expect(afterAssignments[0].attributeOptionId).toEqual("102"); + expect(beforeAssignments[0].id).toEqual(afterAssignments[0].id); + }); + + it.skip("should only delete the assignments for the user that is being pushed", async () => { + // TODO + }); + }); + + describe("Update from Cal.com", () => { + const userDoingAssignment_id = 999; + it("should reset the options(Multi-Select) in the Cal.com User Pool only and keep SCIM Pool options intact when assignValueToUserInOrgBulk is from Cal.com User Pool", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.MULTI_SELECT, + isLocked: false, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + + // In Cal.com user created pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Sales + attributeOptionId: "102", + dsyncId: null, + }); + + const dsync = await createMockDSyncData({ + directoryId: "1", + tenant: "testorg-000", + organizationId: orgId, + }); + + // In SCIM Pool + await createMockAssignment({ + orgId, + membershipId: membership.id, + // Engineering + attributeOptionId: "101", + //@ts-expect-error - prismock can't handle string ids it seems + dsyncId: dsync.id, + }); + + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "marketing", + }, + updater: { + userId: userDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + }); + + expect(attributeToUsers).toHaveLength(2); + + // Cal.com User Pool - Previous value removed and new value added + expect(attributeToUsers[1].attributeOptionId).toEqual("103"); + }); + + it("should not be able to assign values beyond the options for SINGLE_SELECT(enum based) attribute", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.SINGLE_SELECT, + isLocked: false, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "not-already-an-option", + }, + updater: { + userId: userDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + select: { + attributeOption: { + select: { + id: true, + value: true, + }, + }, + }, + }); + + expect(attributeToUsers).toHaveLength(0); + }); + + it("should be able to assign values beyond the options for TEXT(non-enum) attribute", async () => { + await createMockAttribute({ + teamId: orgId, + id: "1", + name: "department", + slug: "department-slug", + type: AttributeType.TEXT, + isLocked: false, + options: [ + { id: "101", value: "engineering", slug: "engineering-slug" }, + { id: "102", value: "sales", slug: "sales-slug" }, + { id: "103", value: "marketing", slug: "marketing-slug" }, + ], + }); + + const { user, membership } = await createMockUserWithMembership({ orgId }); + await assignValueToUserInOrgBulk({ + orgId, + userId: user.id, + attributeLabelToValueMap: { + Department: "not-already-an-option", + }, + updater: { + userId: userDoingAssignment_id, + }, + }); + + const attributeToUsers = await prisma.attributeToUser.findMany({ + where: { + memberId: membership.id, + }, + select: { + attributeOption: { + select: { + id: true, + value: true, + }, + }, + }, + }); + + expect(attributeToUsers).toHaveLength(1); + expect(attributeToUsers[0].attributeOption.value).toEqual("not-already-an-option"); + }); + + it.skip("should only delete the assignments for the user that is being pushed", async () => { + // TODO + }); + }); + }); +}); + +describe("buildPrismaQueriesForAttributeOptionToUser", () => { + describe("when building queries for attribute option to user", () => { + const mockCreator = { userId: 123 }; + const mockMemberId = 456; + const mockOrgId = 789; + + it("should not return any queries when there are no assignments", () => { + const result = buildPrismaQueriesForAttributeOptionToUser({ + orgId: mockOrgId, + memberId: mockMemberId, + existingAttributeOptionAssignments: [], + attributeOptionsToAssign: [], + updater: mockCreator, + }); + + expect(result.attributeToUserCreateManyInput).toHaveLength(0); + expect(result.attributeToUserUpdateManyInput).toBeNull(); + expect(result.attributeToUserDeleteQueries.locked).toBeNull(); + expect(result.attributeToUserDeleteQueries.unlocked).toEqual(null); + }); + + it("should create new assignments for new options", () => { + const attributeOptionsToAssign = [ + { + attribute: { id: "attr1", isLocked: false }, + optionsToAssign: [{ id: "opt1", label: "Option 1" }], + }, + ]; + + const result = buildPrismaQueriesForAttributeOptionToUser({ + orgId: mockOrgId, + memberId: mockMemberId, + existingAttributeOptionAssignments: [], + attributeOptionsToAssign, + updater: mockCreator, + }); + + expect(result.attributeToUserCreateManyInput).toHaveLength(1); + expect(result.attributeToUserCreateManyInput[0]).toEqual({ + memberId: mockMemberId, + attributeOptionId: "opt1", + createdById: mockCreator.userId, + createdByDSyncId: null, + }); + + expect(result.attributeToUserUpdateManyInput).toBeNull(); + expect(result.attributeToUserDeleteQueries.locked).toBeNull(); + expect(result.attributeToUserDeleteQueries.unlocked).toBeNull(); + }); + + it("should handle locked attributes correctly", () => { + const attributeOptionsToAssign = [ + { + attribute: { id: "attr1", isLocked: true }, + optionsToAssign: [{ id: "opt1", label: "Option 1" }], + }, + ]; + + const result = buildPrismaQueriesForAttributeOptionToUser({ + orgId: mockOrgId, + memberId: mockMemberId, + existingAttributeOptionAssignments: [], + attributeOptionsToAssign, + updater: mockCreator, + }); + + expect(result.attributeToUserDeleteQueries.locked).toEqual({ + memberId: mockMemberId, + attributeOption: { + attribute: { + id: { + in: ["attr1"], + }, + }, + }, + }); + }); + + it("should update existing assignments with same values", () => { + const existingAssignments = [ + { + id: "assign1", + attributeOption: { + label: "APAC", + attribute: { id: "territory" }, + }, + }, + ] as any; + + const attributeOptionsToAssign = [ + { + attribute: { id: "territory", isLocked: false }, + optionsToAssign: [{ id: "1", label: "ApAc" }], + }, + ]; + + const result = buildPrismaQueriesForAttributeOptionToUser({ + orgId: mockOrgId, + memberId: mockMemberId, + existingAttributeOptionAssignments: existingAssignments, + attributeOptionsToAssign, + updater: mockCreator, + }); + + expect(result.attributeToUserUpdateManyInput).toEqual({ + where: { + memberId: mockMemberId, + id: { in: ["assign1"] }, + }, + data: { + updatedById: mockCreator.userId, + updatedByDSyncId: null, + }, + }); + }); + }); +}); diff --git a/packages/lib/service/attribute/server/assignValueToUser.ts b/packages/lib/service/attribute/server/assignValueToUser.ts new file mode 100644 index 0000000000..bece2c0833 --- /dev/null +++ b/packages/lib/service/attribute/server/assignValueToUser.ts @@ -0,0 +1,511 @@ +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; + +import { AttributeRepository } from "../../../server/repository/attribute"; +import { AttributeOptionRepository } from "../../../server/repository/attributeOption"; +import { MembershipRepository } from "../../../server/repository/membership"; +import type { AttributeId, AttributeName, BulkAttributeAssigner, AttributeOptionAssignment } from "../types"; +import { + doesSupportMultipleValues, + isAssignmentForLockedAttribute, + isAssignmentForTheSamePool, + isAssignmentSame, + buildSlugFromValue, + canSetValueBeyondOptions, +} from "../utils"; +import { findAssignmentsForMember } from "./utils"; + +const log = logger.getSubLogger({ prefix: ["entity/attribute"] }); + +type AttributesIncludingOptions = Awaited>[number]; +type AttributeOptionsToAssign = { + attribute: { id: AttributeId; isLocked: boolean }; + optionsToAssign: { id: string | null; label: string }[]; +}; + +type AttributeOptionsToAssignWithIdEnsured = { + attribute: { id: AttributeId; isLocked: boolean }; + optionsToAssign: { id: string; label: string }[]; +}; +type AttributeLabelToValueMap = Record; + +const findAttributesByName = async ({ + orgId, + attributeNames, +}: { + orgId: number; + attributeNames: AttributeName[]; +}) => { + const attributesFromDb = await AttributeRepository.findManyByNamesAndOrgIdIncludeOptions({ + attributeNames, + orgId, + }); + + return attributesFromDb.map((attribute) => { + const { name, options, ...rest } = attribute; + + return { + ...rest, + label: name, + options: options.map((option) => { + return { + ...option, + label: option.value, + }; + }), + }; + }); +}; + +const lookupByLabels = ({ + labels, + items, +}: { + labels: string[]; + items: TWithLabel[]; +}) => { + const map = new Map(); + labels.forEach((label) => { + map.set(label, items.find((item) => item.label.toLowerCase() === label.toLowerCase()) ?? null); + }); + return map; +}; + +const lookupByLabel = ({ + label, + items, +}: { + label: string; + items: TWithLabel[]; +}) => { + const map = lookupByLabels({ labels: [label], items }); + return map.get(label); +}; + +export const buildPrismaQueriesForAttributeOptionToUser = ({ + existingAttributeOptionAssignments, + attributeOptionsToAssign, + updater, + memberId, +}: { + orgId: number; + memberId: number; + existingAttributeOptionAssignments: AttributeOptionAssignment[]; + attributeOptionsToAssign: AttributeOptionsToAssignWithIdEnsured[]; + updater: BulkAttributeAssigner; +}) => { + const assignmentsToUpdateIds: string[] = []; + if (!attributeOptionsToAssign.length) { + log.debug("No attribute options to assign, skipping"); + return { + attributeToUserCreateManyInput: [], + attributeToUserUpdateManyInput: null, + attributeToUserDeleteQueries: { + locked: null, + unlocked: null, + }, + }; + } + const { attributeToUserCreateManyInput } = attributeOptionsToAssign.reduce( + (acc, attributeOptionsToAssign) => { + const existingAttributeOptionAssignment = existingAttributeOptionAssignments.find( + (existingAttributeOptionForMember) => existingAttributeOptionForMember.attributeOption.attribute.id + ); + + // Prevent overriding the only value for an unlocked attribute + if ( + existingAttributeOptionAssignment && + !isAssignmentForLockedAttribute({ + assignment: existingAttributeOptionAssignment, + }) && + !doesSupportMultipleValues({ + attribute: existingAttributeOptionAssignment.attributeOption.attribute, + }) && + !isAssignmentForTheSamePool({ + assignment: existingAttributeOptionAssignment, + updater, + }) + ) { + console.warn( + `Attribute ${existingAttributeOptionAssignment.attributeOption.attribute.id} already assigned to user ${memberId} and is not a multi-select attribute. So, skipping` + ); + return acc; + } + + attributeOptionsToAssign.optionsToAssign.forEach((optionToAssign) => { + // Prevent unnecessary recreation of the assignment, to avoid resetting the "weight" + if ( + existingAttributeOptionAssignment && + isAssignmentSame({ + existingAssignment: existingAttributeOptionAssignment, + newOption: optionToAssign, + }) + ) { + log.debug( + `Updating existing assignment ${existingAttributeOptionAssignment.id} as it has same value` + ); + assignmentsToUpdateIds.push(existingAttributeOptionAssignment.id); + return; + } + + acc.attributeToUserCreateManyInput.push({ + memberId, + attributeOptionId: optionToAssign.id, + ...("dsyncId" in updater + ? { createdByDSyncId: updater.dsyncId, createdById: null } + : { + createdById: updater.userId, + createdByDSyncId: null, + }), + }); + }); + + return acc; + }, + { + attributeToUserCreateManyInput: [] as { + memberId: number; + attributeOptionId: string; + createdByDSyncId: string | null; + createdById: number | null; + }[], + } + ); + + const lockedAttributeIds = attributeOptionsToAssign + .filter((option) => option.attribute.isLocked) + .map((option) => option.attribute.id); + + const unlockedAttributeIds = attributeOptionsToAssign + .filter((option) => !option.attribute.isLocked) + .map((option) => option.attribute.id); + + const unlockedAttributesAssignmentIdsInThePool = existingAttributeOptionAssignments + .filter((assignment) => { + return ( + isAssignmentForTheSamePool({ + assignment, + updater, + }) && unlockedAttributeIds.includes(assignment.attributeOption.attribute.id) + ); + }) + .map((existingAttributeOptionForMember) => existingAttributeOptionForMember.id); + + const unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated = + unlockedAttributesAssignmentIdsInThePool.filter((id) => !assignmentsToUpdateIds.includes(id)); + const attributeToUserDeleteQueryWhereClauseForUnlockedAttributes = + unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated.length + ? scopedWhereClause({ + memberId, + whereClause: { + id: { + in: unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated, + }, + }, + }) + : null; + + const attributeToUserDeleteQueryWhereClauseForLockedAttributes = lockedAttributeIds.length + ? scopedWhereClause({ + memberId, + whereClause: { + ...(assignmentsToUpdateIds.length + ? { + id: { + notIn: assignmentsToUpdateIds, + }, + } + : null), + // No matter who assigned the attribute(Cal.com or SCIM), we delete it because the source of truth is SCIM and only one SCIM can be be source of truth at one time. + attributeOption: { + attribute: { + id: { + in: lockedAttributeIds, + }, + }, + }, + }, + }) + : null; + + const attributeToUserUpdateManyInput = assignmentsToUpdateIds.length + ? { + where: scopedWhereClause({ + memberId, + whereClause: { + id: { + in: assignmentsToUpdateIds, + }, + }, + }), + data: { + ...("dsyncId" in updater + ? { updatedByDSyncId: updater.dsyncId, updatedById: null } + : { + updatedById: updater.userId, + updatedByDSyncId: null, + }), + }, + } + : null; + + log.debug("buildPrismaQueriesForAttributeOptionToUser", { + assignmentsToUpdateIds, + lockedAttributeIds, + unlockedAttributesAssignmentIdsInThePool, + unlockedAttributeIds, + existingAttributeOptionAssignments, + updater, + attributeToUserDeleteQueryWhereClauseForLockedAttributes: safeStringify( + attributeToUserDeleteQueryWhereClauseForLockedAttributes + ), + attributeToUserDeleteQueryWhereClauseForUnlockedAttributes: safeStringify( + attributeToUserDeleteQueryWhereClauseForUnlockedAttributes + ), + attributeToUserCreateManyInput: safeStringify(attributeToUserCreateManyInput), + attributeToUserUpdateManyInput, + }); + + return { + attributeToUserUpdateManyInput, + attributeToUserCreateManyInput, + attributeToUserDeleteQueries: { + locked: attributeToUserDeleteQueryWhereClauseForLockedAttributes, + unlocked: attributeToUserDeleteQueryWhereClauseForUnlockedAttributes, + }, + }; + + function scopedWhereClause({ + memberId, + whereClause, + }: { + memberId: number; + whereClause: TWhereClause; + }) { + return { + ...whereClause, + memberId, + }; + } +}; + +const buildPrismaQueryForAttributeOptionCreation = ({ + optionsToCreate, +}: { + optionsToCreate: Record; +}) => { + const attributeOptionCreateManyInput = Object.entries(optionsToCreate).map(([attributeId, options]) => + options.map((option) => ({ + attributeId, + value: option, + slug: buildSlugFromValue({ value: option }), + })) + ); + return attributeOptionCreateManyInput.flat(); +}; + +const createMissingOptionsAndReturnAlongWithExisting = async < + TattributeOptionsToAssign extends AttributeOptionsToAssign +>({ + attributeOptionsToAssignIncludingNonExistentOptions, + orgId, +}: { + attributeOptionsToAssignIncludingNonExistentOptions: TattributeOptionsToAssign[]; + orgId: number; +}) => { + const attributeOptionCreateManyInput = buildPrismaQueryForAttributeOptionCreation({ + optionsToCreate: attributeOptionsToAssignIncludingNonExistentOptions.reduce( + (acc, attributeOptionsToAssign) => { + const { attribute, optionsToAssign } = attributeOptionsToAssign; + acc[attribute.id] = optionsToAssign.filter((option) => !option.id).map((option) => option.label); + return acc; + }, + {} as Record + ), + }); + + await AttributeOptionRepository.createMany({ + createManyInput: attributeOptionCreateManyInput, + }); + + // We need fetch all the attribute options to ensure that we have the newly created options as well. + const allAttributeOptions = ( + await AttributeOptionRepository.findMany({ + orgId, + }) + ).map((attributeOption) => ({ + ...attributeOption, + label: attributeOption.value, + })); + + return attributeOptionsToAssignIncludingNonExistentOptions.map(({ attribute, optionsToAssign }) => { + const optionsWithIdsOfTheSameAttribute = optionsToAssign + .map((option) => + allAttributeOptions.find( + (attributeOption) => + attributeOption.attributeId === attribute.id && + attributeOption.label.toLowerCase() === option.label.toLowerCase() + ) + ) + .filter((option): option is NonNullable => option !== null); + return { + attribute, + optionsToAssign: optionsWithIdsOfTheSameAttribute, + }; + }); +}; + +const buildAttributeOptionsToAssign = async ({ + attributeLabelToValueMap, + allAttributesBeingAssigned, + orgId, +}: { + attributeLabelToValueMap: AttributeLabelToValueMap; + allAttributesBeingAssigned: AttributesIncludingOptions[]; + orgId: number; +}) => { + const attributeOptionsToAssignIncludingNonExistentOptions = Object.entries(attributeLabelToValueMap) + .map(([attributeLabel, value]) => { + const valueAsArray = value instanceof Array ? value : [value]; + const attribute = lookupByLabel({ label: attributeLabel, items: allAttributesBeingAssigned }); + if (!attribute) { + console.warn(`Attribute ${attributeLabel} not found, will not be assigned to user`); + return null; + } + + const labelToFoundOptionMap = lookupByLabels({ labels: valueAsArray, items: attribute.options }); + const labelsEntries = Array.from(labelToFoundOptionMap.entries()); + const optionsToAssign = labelsEntries + .filter(([_, option]) => { + return canSetValueBeyondOptions({ attribute }) ? true : !!option; + }) + .map(([label]) => { + const foundLabel = labelToFoundOptionMap.get(label) ?? null; + return { + label, + ...foundLabel, + }; + }); + + return { + attribute, + optionsToAssign: optionsToAssign.map((option) => ({ + label: option.label, + id: option.id ?? null, + })), + }; + }) + .filter((option): option is NonNullable => option !== null); + + return createMissingOptionsAndReturnAlongWithExisting({ + attributeOptionsToAssignIncludingNonExistentOptions, + orgId, + }); +}; + +/** + * For a user in an org, it assigns all the attributes with their values as per the attributeLabelToValueMap. + */ +export const assignValueToUserInOrgBulk = async ({ + orgId, + userId, + attributeLabelToValueMap, + updater, +}: { + orgId: number; + userId: number; + attributeLabelToValueMap: AttributeLabelToValueMap; + updater: BulkAttributeAssigner; +}) => { + const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId }); + const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 }; + if (!membership) { + console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`); + return defaultReturn; + } + + const { id: memberId } = membership; + const attributeNames = Object.keys(attributeLabelToValueMap); + + const allAttributesBeingAssigned = await findAttributesByName({ + orgId, + attributeNames, + }); + + if (!allAttributesBeingAssigned.length) { + console.warn(`None of the attributes ${attributeNames.join(", ")} found in org ${orgId}`); + return defaultReturn; + } + + const attributeOptionsToAssign = await buildAttributeOptionsToAssign({ + attributeLabelToValueMap, + allAttributesBeingAssigned, + orgId, + }); + + // We need to fetch all the assigned options even those that aren't being set directly in call. + // We need to decide on the fate of all existing assignments + const existingAttributeOptionAssignments = await findAssignmentsForMember({ memberId }); + + const { attributeToUserUpdateManyInput, attributeToUserCreateManyInput, attributeToUserDeleteQueries } = + buildPrismaQueriesForAttributeOptionToUser({ + orgId, + memberId, + attributeOptionsToAssign, + existingAttributeOptionAssignments, + updater, + }); + + const { numOfAttributeOptionsDeleted, numOfAttributeOptionsSet, numOfAttributeOptionsUpdated } = + await prisma.$transaction(async (tx) => { + // First delete all existing options, to allow them to be set again. + const deleteQueriesResult = await Promise.all( + Object.entries(attributeToUserDeleteQueries).map(([_, attributeToUserDeleteQuery]) => + attributeToUserDeleteQuery + ? tx.attributeToUser.deleteMany({ + where: attributeToUserDeleteQuery, + }) + : Promise.resolve({ count: 0 }) + ) + ); + + // Then set the new options. + const { count: numOfAttributeOptionsSet } = await tx.attributeToUser.createMany({ + data: attributeToUserCreateManyInput, + // In case the values already exist, we skip them instead of throwing an error. + skipDuplicates: true, + }); + + let numOfAttributeOptionsUpdated = 0; + if (attributeToUserUpdateManyInput) { + ({ count: numOfAttributeOptionsUpdated } = await tx.attributeToUser.updateMany({ + where: attributeToUserUpdateManyInput?.where, + data: attributeToUserUpdateManyInput?.data, + })); + } + + const numOfAttributeOptionsDeleted = deleteQueriesResult.reduce( + (acc, query) => acc + (query.count ?? 0), + 0 + ); + + return { + numOfAttributeOptionsDeleted, + numOfAttributeOptionsSet, + numOfAttributeOptionsUpdated, + }; + }); + + log.debug({ + numOfAttributeOptionsSet, + numOfAttributeOptionsDeleted, + numOfAttributeOptionsUpdated, + }); + + return { + numOfAttributeOptionsSet, + numOfAttributeOptionsDeleted, + numOfAttributeOptionsUpdated, + }; +}; diff --git a/packages/lib/service/attribute/server/getAttributes.test.ts b/packages/lib/service/attribute/server/getAttributes.test.ts new file mode 100644 index 0000000000..f4eb615e86 --- /dev/null +++ b/packages/lib/service/attribute/server/getAttributes.test.ts @@ -0,0 +1,446 @@ +import prismock from "../../../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, beforeEach } from "vitest"; + +import type { AttributeOption } from "@calcom/prisma/client"; +import { AttributeType, MembershipRole } from "@calcom/prisma/enums"; + +import { + getAttributesForTeam, + getTeamMembersWithAttributeOptionValuePerAttribute, + getUsersAttributes, +} from "./getAttributes"; + +// Helper functions to create test data +async function createMockAttribute({ + orgId, + options, + ...attributeData +}: { + orgId: number; + id: string; + type: AttributeType; + name: string; + slug: string; + options: Pick[]; +}) { + const attribute = await prismock.attribute.create({ + data: { + ...attributeData, + teamId: orgId, + enabled: true, + usersCanEditRelation: true, + isWeightsEnabled: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + await prismock.attributeOption.createMany({ + data: options.map((option) => ({ + ...option, + attributeId: attribute.id, + })), + }); + + return attribute; +} + +async function createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId, +}: { + orgId: number; + teamId: number; +}) { + const user = await prismock.user.create({ + data: { + name: "Test User", + email: "test@test.com", + }, + }); + + // Create org membership + const orgMembership = await prismock.membership.create({ + data: { + role: MembershipRole.MEMBER, + disableImpersonation: false, + accepted: true, + teamId: orgId, + userId: user.id, + }, + }); + + // Create team membership + const teamMembership = await prismock.membership.create({ + data: { + role: MembershipRole.MEMBER, + disableImpersonation: false, + accepted: true, + teamId: teamId, + userId: user.id, + }, + }); + + return { user, teamMembership, orgMembership }; +} + +async function createMockTeam({ orgId }: { orgId: number | null }) { + return await prismock.team.create({ + data: { + name: "Test Team", + slug: "test-team", + parentId: orgId, + }, + }); +} + +async function createMockAttributeAssignment({ + membershipId, + attributeOptionId, +}: { + membershipId: number; + attributeOptionId: string; +}) { + return await prismock.attributeToUser.create({ + data: { + memberId: membershipId, + attributeOptionId, + }, + }); +} + +describe("getAttributes", () => { + const orgId = 1001; + + beforeEach(async () => { + // @ts-expect-error reset is a method on Prismock + await prismock.reset(); + }); + + describe("getAttributesForTeam", () => { + it("should return empty array for team without parent (non-org team)", async () => { + const teamWithoutParent = await createMockTeam({ orgId: null }); + await createMockAttribute({ + orgId, + id: "attr1", + name: "Department", + slug: "department", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt1", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }, + { id: "opt2", value: "Sales", slug: "sales", isGroup: false, contains: [] }, + ], + }); + + const attributes = await getAttributesForTeam({ teamId: teamWithoutParent.id }); + expect(attributes).toHaveLength(0); + }); + + it("should return attributes assigned to team members", async () => { + const team = await createMockTeam({ orgId }); + const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId: team.id, + }); + + const attribute = await createMockAttribute({ + orgId, + id: "attr1", + name: "Department", + slug: "department", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt1", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }, + { id: "opt2", value: "Sales", slug: "sales", isGroup: false, contains: [] }, + ], + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + attributeOptionId: "opt1", + }); + + const attributes = await getAttributesForTeam({ teamId: team.id }); + + expect(attributes).toHaveLength(1); + expect(attributes[0]).toMatchObject({ + id: attribute.id, + name: attribute.name, + type: attribute.type, + }); + }); + }); + + describe("getTeamMembersWithAttributeOptionValuePerAttribute", () => { + it("should return team members with their assigned attributes", async () => { + const team = await createMockTeam({ orgId }); + const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId: team.id, + }); + + await createMockAttribute({ + orgId, + id: "attr1", + name: "Department", + slug: "department", + type: AttributeType.MULTI_SELECT, + options: [ + { id: "engineering-id", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }, + { id: "sales-id", value: "Sales", slug: "sales", isGroup: false, contains: [] }, + { + id: "engineering-and-sales-id", + value: "EngineeringAndSales", + slug: "engineering-and-sales", + isGroup: true, + contains: ["engineering-id", "sales-id"], + }, + ], + }); + + await createMockAttribute({ + orgId, + id: "attr2", + name: "Territory", + slug: "territory", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "india-id", value: "India", slug: "india", isGroup: false, contains: [] }, + { id: "china-id", value: "China", slug: "china", isGroup: false, contains: [] }, + ], + }); + + // Unassigned attribute + await createMockAttribute({ + orgId, + id: "attr3", + name: "Attr3", + slug: "attr3", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt1", value: "Opt1", slug: "opt1", isGroup: false, contains: [] }, + { id: "opt2", value: "Opt2", slug: "opt2", isGroup: false, contains: [] }, + ], + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + // Assigning a group option that has its sub-options not assigned directly to the user + attributeOptionId: "engineering-and-sales-id", + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + attributeOptionId: "india-id", + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + attributeOptionId: "engineering-id", + }); + + const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id }); + + expect(teamMembers).toHaveLength(1); + expect(teamMembers[0]).toEqual({ + userId: user.id, + attributes: { + attr1: { + type: AttributeType.MULTI_SELECT, + attributeOption: [ + { + isGroup: true, + value: "EngineeringAndSales", + contains: [ + { + id: "engineering-id", + slug: "engineering", + value: "Engineering", + }, + { id: "sales-id", slug: "sales", value: "Sales" }, + ], + }, + { + isGroup: false, + value: "Engineering", + contains: [], + }, + ], + }, + attr2: { + type: AttributeType.SINGLE_SELECT, + attributeOption: { isGroup: false, value: "India", contains: [] }, + }, + // attr3 - unassigned isn't here + }, + }); + }); + + it("should return contains correctly for group options that have sub-options not assigned directly to the user", async () => { + const team = await createMockTeam({ orgId }); + const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId: team.id, + }); + + await createMockAttribute({ + orgId, + id: "attr1", + name: "Department", + slug: "department", + type: AttributeType.MULTI_SELECT, + options: [ + { id: "engineering-id", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }, + { id: "sales-id", value: "Sales", slug: "sales", isGroup: false, contains: [] }, + { + id: "engineering-and-sales-id", + value: "EngineeringAndSales", + slug: "engineering-and-sales", + isGroup: true, + contains: ["engineering-id", "sales-id"], + }, + ], + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + // Assigning a group option that has its sub-options not assigned directly to the user + attributeOptionId: "engineering-and-sales-id", + }); + + const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id }); + + expect(teamMembers).toHaveLength(1); + expect(teamMembers[0]).toEqual({ + userId: user.id, + attributes: { + attr1: { + type: AttributeType.MULTI_SELECT, + attributeOption: { + isGroup: true, + value: "EngineeringAndSales", + contains: [ + { + id: "engineering-id", + slug: "engineering", + value: "Engineering", + }, + { id: "sales-id", slug: "sales", value: "Sales" }, + ], + }, + }, + }, + }); + }); + + it("should return no attributes for a different org", async () => { + const team = await createMockTeam({ orgId }); + const otherTeam = await createMockTeam({ orgId: 1002 }); + const { teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId: team.id, + }); + + await createMockAttribute({ + orgId, + id: "attr1", + name: "Department", + slug: "department", + type: AttributeType.MULTI_SELECT, + options: [ + { id: "engineering-id", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }, + { id: "sales-id", value: "Sales", slug: "sales", isGroup: false, contains: [] }, + { + id: "engineering-and-sales-id", + value: "EngineeringAndSales", + slug: "engineering-and-sales", + isGroup: true, + contains: ["engineering-id", "sales-id"], + }, + ], + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + // Assigning a group option that has its sub-options not assigned directly to the user + attributeOptionId: "engineering-and-sales-id", + }); + + const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: otherTeam.id }); + + expect(teamMembers).toHaveLength(0); + }); + + it("should handle multi-select attributes correctly", async () => { + const team = await createMockTeam({ orgId }); + const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId: team.id, + }); + + await createMockAttribute({ + orgId, + id: "attr1", + name: "Skills", + slug: "skills", + type: AttributeType.MULTI_SELECT, + options: [ + { id: "opt1", value: "JavaScript", slug: "javascript", isGroup: false, contains: [] }, + { id: "opt2", value: "Python", slug: "python", isGroup: false, contains: [] }, + ], + }); + + // Assign multiple skills + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + attributeOptionId: "opt1", + }); + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + attributeOptionId: "opt2", + }); + + const teamMembers = await getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: team.id }); + + expect(teamMembers).toHaveLength(1); + expect(teamMembers[0].attributes.attr1.attributeOption).toEqual([ + { value: "JavaScript", isGroup: false, contains: [] }, + { value: "Python", isGroup: false, contains: [] }, + ]); + }); + }); + + describe("getUsersAttributes", () => { + it("should return attributes assigned to specific user in team", async () => { + const team = await createMockTeam({ orgId }); + const { user, teamMembership } = await createMockUserHavingMembershipWithBothTeamAndOrg({ + orgId, + teamId: team.id, + }); + + const attribute = await createMockAttribute({ + orgId, + id: "attr1", + name: "Department", + slug: "department", + type: AttributeType.SINGLE_SELECT, + options: [{ id: "opt1", value: "Engineering", slug: "engineering", isGroup: false, contains: [] }], + }); + + await createMockAttributeAssignment({ + membershipId: teamMembership.id, + attributeOptionId: "opt1", + }); + + const userAttributes = await getUsersAttributes({ userId: user.id, teamId: team.id }); + + expect(userAttributes).toHaveLength(1); + expect(userAttributes[0]).toMatchObject({ + id: attribute.id, + name: attribute.name, + type: attribute.type, + }); + }); + }); +}); diff --git a/packages/lib/service/attribute/server/getAttributes.ts b/packages/lib/service/attribute/server/getAttributes.ts new file mode 100644 index 0000000000..8826b2096f --- /dev/null +++ b/packages/lib/service/attribute/server/getAttributes.ts @@ -0,0 +1,233 @@ +// TODO: Queries in this file are not optimized. Need to optimize them. +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { AttributeType } from "@calcom/prisma/enums"; + +import type { AttributeId } from "../types"; +import { findAllAttributesWithTheirOptions } from "./utils"; + +type UserId = number; + +export type Attribute = { + name: string; + slug: string; + type: AttributeType; + id: string; + options: { + id: string; + value: string; + slug: string; + }[]; +}; + +export type AttributeOptionValue = { + value: string; + isGroup: boolean; + contains: { + id: string; + slug: string; + value: string; + }[]; +}; + +export type AttributeOptionValueWithType = { + type: Attribute["type"]; + attributeOption: AttributeOptionValue | AttributeOptionValue[]; +}; + +/** + * Note: assignedAttributeOptions[x].attributeOption isn't unique. It is returned multiple times depending on how many users it is assigned to + */ +async function getAssignedAttributeOptions({ teamId }: { teamId: number }) { + const log = logger.getSubLogger({ prefix: ["getAssignedAttributeOptions"] }); + const whereClauseForAssignment = { + member: { + user: { + teams: { + some: { + teamId, + }, + }, + }, + }, + }; + + log.debug( + safeStringify({ + teamId, + whereClauseForAssignment, + }) + ); + + const assignedAttributeOptions = await prisma.attributeToUser.findMany({ + where: whereClauseForAssignment, + select: { + member: { + select: { + userId: true, + }, + }, + attributeOption: { + select: { + id: true, + value: true, + slug: true, + contains: true, + isGroup: true, + attribute: { + select: { id: true, name: true, type: true, slug: true }, + }, + }, + }, + }, + }); + + log.debug("Returned assignedAttributeOptions", safeStringify({ assignedAttributeOptions })); + return assignedAttributeOptions; +} + +async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId: number; userId?: number }) { + const log = logger.getSubLogger({ prefix: ["getAttributeToUserWithMembershipAndAttributes"] }); + + const whereClauseForAttributesAssignedToMembersOfTeam = { + options: { + some: { + assignedUsers: { + some: { + member: { + userId, + user: { + teams: { + some: { + teamId, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + log.debug( + safeStringify({ + teamId, + whereClauseForAttributesAssignedToMembersOfTeam, + }) + ); + + const assignedAttributeOptions = await prisma.attribute.findMany({ + where: whereClauseForAttributesAssignedToMembersOfTeam, + select: { + id: true, + name: true, + type: true, + options: { + select: { + id: true, + value: true, + slug: true, + }, + }, + slug: true, + }, + }); + return assignedAttributeOptions; +} + +export async function getAttributesForTeam({ teamId }: { teamId: number }) { + const attributes = await getAttributesAssignedToMembersOfTeam({ teamId }); + return attributes satisfies Attribute[]; +} + +export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) { + const [assignedAttributeOptions, allAttributesWithTheirOptions] = await Promise.all([ + getAssignedAttributeOptions({ teamId }), + // We need to fetch even the unassigned options, to know what all sub-options are there in an option group. Because it is possible that sub-options aren't assigned directly to the user + findAllAttributesWithTheirOptions({ teamId }), + ]); + + const teamMembersThatHaveOptionAssigned = assignedAttributeOptions.reduce((acc, attributeToUser) => { + const { userId } = attributeToUser.member; + const { attribute, ...attributeOption } = attributeToUser.attributeOption; + + 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 }), + }; + + 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, + }; + } + return acc; + }, {} as Record }>); + + return Object.values(teamMembersThatHaveOptionAssigned); + + /** + * Transforms ["optionId1", "optionId2"] to [{ + * id: "optionId1", + * value: "optionValue1", + * slug: "optionSlug1", + * }, { + * id: "optionId2", + * value: "optionValue2", + * slug: "optionSlug2", + * }] + */ + function tranformContains({ + contains, + attribute, + }: { + contains: string[]; + attribute: { id: string; name: string }; + }) { + return contains + .map((optionId) => { + const allOptions = allAttributesWithTheirOptions.get(attribute.id); + const option = allOptions?.find((option) => option.id === optionId); + if (!option) { + console.error( + `Enriching "contains" for attribute ${ + attribute.name + }: Option with id ${optionId} not found. Looked up in ${JSON.stringify(allOptions)}` + ); + return null; + } + return { + id: option.id, + value: option.value, + slug: option.slug, + }; + }) + .filter((option): option is NonNullable => option !== null); + } +} + +export async function getUsersAttributes({ userId, teamId }: { userId: number; teamId: number }) { + return await getAttributesAssignedToMembersOfTeam({ teamId, userId }); +} diff --git a/packages/lib/service/attribute/server/scim-attribute-sync.md b/packages/lib/service/attribute/server/scim-attribute-sync.md new file mode 100644 index 0000000000..4208c3f240 --- /dev/null +++ b/packages/lib/service/attribute/server/scim-attribute-sync.md @@ -0,0 +1,21 @@ +- To be moved to proper documentation location + +1. When a user is assigned to the integration in Okta, they are created in Cal.com with appropriate attributes. +2. Only attributes that are defined in Cal.com are synced. + - Attributes are mapped by label, i.e., the external variable name of the custom attribute in Okta must match the label of the attribute in Cal.com (in a case-insensitive manner). +3. Only two types of custom attributes are currently supported (Number type support is pending): + - string + - array of strings +4. If an Okta custom attribute maps to a Multi-Select or Single-Select attribute in Cal.com, the provided values must be among the options defined for that attribute in Cal.com. + - Options are matched by label in a case-insensitive manner, similar to attribute labels + - Any option not defined on the attribute is ignored and not synced +5. If the attribute is locked in Cal.com, sync from Okta completely overrides the attribute's value for that user in Cal.com. Okta serves as the source of truth in this case. + - After sync, no values will remain in Cal.com for that user's custom attribute unless they are defined in Okta. +6. If the attribute is not locked in Cal.com, the expectation is that the attribute is marked as 'Create Only' in Okta and shouldn't be synced back to Cal.com, after user's creation. + - But in case if the attribute is still synced back to Cal.com, we handle it in following way: + - We consider two pools of assignments for the attribute: + - Directory Sync Pool: Assignments created by Directory Sync + - Cal.com Pool: Assignments created by via Cal.com + - A sync from Okta, would ensure that all assignments from Directory Sync Pool are removed and only those remain that are provided by Okta. + - Any update in Cal.com is restricted to assignments in Cal.com Pool only and doesn't impact Directory Sync Pool. + - In case an option already in Cal.com pool is assigned to a user through Directory sync, the ownership for that assignment is transferred to Directory Sync and it moves to Directory Sync Pool. diff --git a/packages/lib/service/attribute/server/utils.ts b/packages/lib/service/attribute/server/utils.ts new file mode 100644 index 0000000000..5bcd7f6942 --- /dev/null +++ b/packages/lib/service/attribute/server/utils.ts @@ -0,0 +1,87 @@ +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import { AttributeToUserRepository } from "../../../server/repository/attributeToUser"; +import type { AttributeId } from "../types"; + +const log = logger.getSubLogger({ prefix: ["entity/attribute"] }); + +async function findTeamById({ teamId }: { teamId: number }) { + return prisma.team.findUnique({ where: { id: teamId } }); +} + +/** + * Returns all the options for all the attributes for a team + */ +const findAllAttributeOptions = async ({ teamId }: { teamId: number }) => { + const team = await findTeamById({ teamId }); + + // A non-org team doesn't have attributes + if (!team || !team.parentId) { + return []; + } + + return await prisma.attributeOption.findMany({ + where: { + attribute: { + teamId: team.parentId, + }, + }, + select: { + id: true, + value: true, + slug: true, + contains: true, + isGroup: true, + attribute: true, + }, + }); +}; + +/** + * Ensures all attributes are their with all their options(only assigned options) mapped to them + */ +export const findAllAttributesWithTheirOptions = async ({ teamId }: { teamId: number }) => { + const allOptionsOfAllAttributes = await findAllAttributeOptions({ teamId }); + const attributeOptionsMap = new Map< + AttributeId, + { + id: string; + value: string; + slug: string; + contains: string[]; + isGroup: boolean; + }[] + >(); + allOptionsOfAllAttributes.forEach((_attributeOption) => { + const { attribute, ...attributeOption } = _attributeOption; + const existingOptionsArray = attributeOptionsMap.get(attribute.id); + if (!existingOptionsArray) { + attributeOptionsMap.set(attribute.id, [attributeOption]); + } else { + // We already have the options for this attribute + existingOptionsArray.push(attributeOption); + } + }); + return attributeOptionsMap; +}; + +export const getWhereClauseForAttributeOptionsManagedByCalcom = () => { + // Neither created nor updated by DSync + return { + createdByDSyncId: null, + // An option created by Cal.com can be updated by DSync, in that case the ownership is transferred to DSync + updatedByDSyncId: null, + }; +}; + +export const findAssignmentsForMember = async ({ memberId }: { memberId: number }) => { + const assignments = await AttributeToUserRepository.findManyIncludeAttribute({ memberId }); + return assignments.map((assignment) => ({ + ...assignment, + attributeOption: { + ...assignment.attributeOption, + label: assignment.attributeOption.value, + }, + })); +}; diff --git a/packages/lib/service/attribute/types.d.ts b/packages/lib/service/attribute/types.d.ts new file mode 100644 index 0000000000..c7be86cf31 --- /dev/null +++ b/packages/lib/service/attribute/types.d.ts @@ -0,0 +1,18 @@ +import type { Attribute, AttributeToUser, AttributeOption } from "@calcom/prisma/client"; + +export type AttributeName = string; +export type AttributeId = string; +export type BulkAttributeAssigner = + | { + dsyncId: string; + } + | { + userId: number; + }; + +export type AttributeOptionAssignment = AttributeToUser & { + attributeOption: Omit & { + label: string; + attribute: Attribute; + }; +}; diff --git a/packages/lib/service/attribute/utils.ts b/packages/lib/service/attribute/utils.ts new file mode 100644 index 0000000000..7edaad5d6f --- /dev/null +++ b/packages/lib/service/attribute/utils.ts @@ -0,0 +1,87 @@ +/** + * Utility functions for attribute service. Shared across server and client. + */ +import slugify from "@calcom/lib/slugify"; +import { AttributeType } from "@calcom/prisma/client"; + +import type { AttributeOptionAssignment, BulkAttributeAssigner } from "./types"; + +/** + * What is a pool? + * There can be two pools: + * 1. SCIM Pool - All assignments in this pool are created/updated by SCIM. + * 2. Cal.com User Pool - All assignments in this pool are created/updated by Cal.com Users. + */ +export const isAssignmentForTheSamePool = ({ + assignment, + updater, +}: { + assignment: Pick< + AttributeOptionAssignment, + "createdByDSyncId" | "updatedByDSyncId" | "createdById" | "updatedById" + >; + updater: BulkAttributeAssigner; +}) => { + if ("dsyncId" in updater) { + // Cal.com user updated an assignment created by SCIM. It no longer belongs to the SCIM pool. + if (assignment.updatedById) { + return false; + } + // Either SCIM created the assignment or updated it + return !!assignment.updatedByDSyncId || !!assignment.createdByDSyncId; + } + // SCIM neither created nor updated the assignment, it has to belong to the only left pool(i.e. Cal.com User Pool) + return !assignment.createdByDSyncId && !assignment.updatedByDSyncId; +}; + +export const isAssignmentForLockedAttribute = ({ + assignment, +}: { + assignment: { + attributeOption: { + attribute: { + isLocked: boolean; + }; + }; + }; +}) => { + return assignment.attributeOption.attribute.isLocked; +}; + +export const isAssignmentSame = ({ + existingAssignment, + newOption, +}: { + existingAssignment: { attributeOption: { label: string } }; + newOption: { label: string }; +}) => { + return existingAssignment.attributeOption.label.toLowerCase() === newOption.label.toLowerCase(); +}; + +export const doesSupportMultipleValues = ({ attribute }: { attribute: { type: AttributeType } }) => { + return attribute.type === AttributeType.MULTI_SELECT; +}; + +export const buildSlugFromValue = ({ value }: { value: string }) => { + return slugify(value); +}; + +export const hasOptions = ({ + attribute, +}: { + attribute: { + type: AttributeType; + }; +}) => { + return attribute.type === AttributeType.MULTI_SELECT || attribute.type === AttributeType.SINGLE_SELECT; +}; + +export const canSetValueBeyondOptions = ({ + attribute, +}: { + attribute: { + type: AttributeType; + }; +}) => { + return !hasOptions({ attribute }); +}; diff --git a/packages/prisma/migrations/20241126132214_scim_attributes_sync_related_attrs_added/migration.sql b/packages/prisma/migrations/20241126132214_scim_attributes_sync_related_attrs_added/migration.sql new file mode 100644 index 0000000000..5ccbbdb9f2 --- /dev/null +++ b/packages/prisma/migrations/20241126132214_scim_attributes_sync_related_attrs_added/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "Attribute" ADD COLUMN "isLocked" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "AttributeOption" ADD COLUMN "contains" TEXT[], +ADD COLUMN "isGroup" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "AttributeToUser" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "createdByDSyncId" TEXT, +ADD COLUMN "createdById" INTEGER, +ADD COLUMN "updatedAt" TIMESTAMP(3), +ADD COLUMN "updatedByDSyncId" TEXT, +ADD COLUMN "updatedById" INTEGER; + +-- AddForeignKey +ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_createdByDSyncId_fkey" FOREIGN KEY ("createdByDSyncId") REFERENCES "DSyncData"("directoryId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_updatedByDSyncId_fkey" FOREIGN KEY ("updatedByDSyncId") REFERENCES "DSyncData"("directoryId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d2596c05dd..e72c7250e4 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -355,6 +355,8 @@ model User { referralLinkId String? features UserFeatures[] reassignedBookings Booking[] @relation("reassignByUser") + createdAttributeToUsers AttributeToUser[] @relation("createdBy") + updatedAttributeToUsers AttributeToUser[] @relation("updatedBy") createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations") updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") createdWatchlists Watchlist[] @relation("CreatedWatchlists") @@ -369,6 +371,7 @@ model User { @@index([identityProvider]) @@index([identityProviderId]) @@map(name: "users") + } model NotificationsSubscriptions { @@ -1499,6 +1502,9 @@ model DSyncData { organizationId Int? @unique org OrganizationSettings? @relation(fields: [organizationId], references: [organizationId], onDelete: Cascade) teamGroupMapping DSyncTeamGroupMapping[] + + createdAttributeToUsers AttributeToUser[] @relation("createdByDSync") + updatedAttributeToUsers AttributeToUser[] @relation("updatedByDSync") } model DSyncTeamGroupMapping { @@ -1580,6 +1586,12 @@ model AttributeOption { attributeId String value String slug String + isGroup Boolean @default(false) + // It is a list of AttributeOptions ids that are contained in the group option + // You could think of a person having the group option to actually have all the options in the contains list. + // We are not using relation here because it would be a many to many relation because a group option can contain many non-group options and a non-group option can be contained in many group options + // Such a relation would require its own table to be managed and we don't need it for now. + contains String[] assignedUsers AttributeToUser[] } @@ -1602,6 +1614,7 @@ model Attribute { updatedAt DateTime @updatedAt options AttributeOption[] isWeightsEnabled Boolean @default(false) + isLocked Boolean @default(false) } model AttributeToUser { @@ -1612,7 +1625,21 @@ model AttributeToUser { attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade) attributeOptionId String + weight Int? + + // We don't intentionally delete assignments on deletion of a user/directory sync + createdAt DateTime @default(now()) + createdById Int? + createdBy User? @relation("createdBy", fields: [createdById], references: [id], onDelete: SetNull) + createdByDSyncId String? + createdByDSync DSyncData? @relation("createdByDSync", fields: [createdByDSyncId], references: [directoryId], onDelete: SetNull) + + updatedAt DateTime? @updatedAt + updatedBy User? @relation("updatedBy", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById Int? + updatedByDSyncId String? + updatedByDSync DSyncData? @relation("updatedByDSync", fields: [updatedByDSyncId], references: [directoryId], onDelete: SetNull) @@unique([memberId, attributeOptionId]) } diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts index f1568f4a6b..73b0f2c05e 100644 --- a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts @@ -1,3 +1,4 @@ +import { getWhereClauseForAttributeOptionsManagedByCalcom } from "@calcom/lib/service/attribute/server/utils"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; @@ -32,9 +33,11 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => { teamId: org.id, }, select: { + name: true, id: true, type: true, options: true, + isLocked: true, }, }); @@ -90,7 +93,14 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => { // const promises: Promise<{ id: string }>[] = []; - input.attributes.map(async (attribute) => { + const unlockedAttributesInInput = input.attributes.filter((attribute) => { + const attributeFromDb = attributes.find((a) => a.id === attribute.id); + return !attributeFromDb?.isLocked; + }); + + const lockedAttributesFromDb = attributes.filter((attribute) => attribute.isLocked); + + unlockedAttributesInInput.map(async (attribute) => { // TEXT, NUMBER if (attribute.value && !attribute.options) { const valueAsString = String(attribute.value); @@ -160,6 +170,7 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => { }, }, memberId: membership.id, + ...getWhereClauseForAttributeOptionsManagedByCalcom(), NOT: { id: { in: options.map((option) => option.value), @@ -194,6 +205,7 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => { await prisma.attributeToUser.deleteMany({ where: { memberId: membership.id, + ...getWhereClauseForAttributeOptionsManagedByCalcom(), attributeOption: { attribute: { id: attribute.id, @@ -206,7 +218,11 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => { return { success: true, - message: "Attributes assigned successfully", + message: lockedAttributesFromDb.length + ? `Attributes assigned successfully. Locked attributes ${lockedAttributesFromDb + .map((attribute) => attribute.name) + .join(", ")} were not assigned.` + : "Attributes assigned successfully.", }; }; diff --git a/packages/trpc/server/routers/viewer/attributes/create.handler.ts b/packages/trpc/server/routers/viewer/attributes/create.handler.ts index 0dd8a62b4f..5ba65a8081 100644 --- a/packages/trpc/server/routers/viewer/attributes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/create.handler.ts @@ -39,6 +39,7 @@ const createAttributesHandler = async ({ input, ctx }: GetOptions) => { slug, name: input.name, type: input.type, + isLocked: input.isLocked, teamId: org.id, }, }); diff --git a/packages/trpc/server/routers/viewer/attributes/create.schema.ts b/packages/trpc/server/routers/viewer/attributes/create.schema.ts index 52154b328e..505e6d5307 100644 --- a/packages/trpc/server/routers/viewer/attributes/create.schema.ts +++ b/packages/trpc/server/routers/viewer/attributes/create.schema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const createAttributeSchema = z.object({ name: z.string(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), + isLocked: z.boolean().optional(), options: z.array(z.object({ value: z.string() })), }); diff --git a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts index 95e32fe07b..9551a874c7 100644 --- a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts @@ -23,7 +23,8 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { }); } - const options = input.options; + // If an option is removed, it is to be removed from contains of corresponding group as well if any + const options = getOptionsWithValidContains(input.options); const foundAttribute = await prisma.attribute.findUnique({ where: { @@ -50,6 +51,7 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { name: input.name, type: input.type, teamId: org.id, + isLocked: input.isLocked, }, select: { id: true, @@ -62,7 +64,6 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { await prisma.$transaction(async (tx) => { const updateOptions = options.filter((option) => option.id !== undefined && option.id !== ""); const updatedOptionsIds = updateOptions.map((option) => option.id!); - // We need to delete all options that are not present in this UpdateOptions.id (as they have been deleted) await tx.attributeOption.deleteMany({ where: { @@ -87,6 +88,8 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { data: { value: option.value, slug: slugify(option.value), + isGroup: option.isGroup, + contains: option.contains, }, }); }); @@ -97,6 +100,8 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { data: { attributeId: attributes.id, value: option.value, + isGroup: option.isGroup, + contains: option.contains, slug: slugify(option.value), }, }); @@ -109,6 +114,29 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { return attributes; }; +/** + * Ensures that contains has no non-existent sub-options + */ +function getOptionsWithValidContains(options: ZEditAttributeSchema["options"]) { + return options.map(({ contains, ...option }) => { + if (!contains) + return { + ...option, + contains: [], + }; + const possibleSubOptions = options + .filter((option) => !option.isGroup) + .filter((option): option is typeof option & { id: string } => option.id !== undefined); + + const possibleSubOptionsIds = possibleSubOptions.map((option) => option.id); + + return { + ...option, + contains: contains.filter((subOptionId) => possibleSubOptionsIds.includes(subOptionId)), + }; + }); +} + async function validateOptionsBelongToAttribute( options: ZEditAttributeSchema["options"], attributeId: string @@ -117,7 +145,6 @@ async function validateOptionsBelongToAttribute( const optionsWithId = options .filter((option) => option.id !== undefined && option.id !== "") .map((option) => option.id!); - console.log("optionsWithId", optionsWithId); // Check all ids of options passed in are owned by the attribute const optionsWithIdOwnedByAttribute = await prisma.attributeOption.findMany({ diff --git a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts index 9c23e7fa75..d82934c5d4 100644 --- a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts +++ b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts @@ -4,7 +4,15 @@ export const editAttributeSchema = z.object({ attributeId: z.string(), name: z.string(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), - options: z.array(z.object({ value: z.string(), id: z.string().optional() })), + isLocked: z.boolean().optional(), + options: z.array( + z.object({ + value: z.string(), + id: z.string().optional(), + isGroup: z.boolean().optional(), + contains: z.array(z.string()).optional(), + }) + ), }); export type ZEditAttributeSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/attributes/get.handler.ts b/packages/trpc/server/routers/viewer/attributes/get.handler.ts index 0e674f38aa..60e16ce6ac 100644 --- a/packages/trpc/server/routers/viewer/attributes/get.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/get.handler.ts @@ -33,10 +33,13 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => { id: true, name: true, type: true, + isLocked: true, options: { select: { id: true, value: true, + contains: true, + isGroup: true, _count: { select: { assignedUsers: true, @@ -61,9 +64,16 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => { const attrReturnValue = z.object({ id: z.string(), name: z.string(), + isLocked: z.boolean().optional(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), options: z.array( - z.object({ value: z.string(), id: z.string().optional(), assignedUsers: z.number().optional() }) + z.object({ + value: z.string(), + id: z.string().optional(), + isGroup: z.boolean().optional(), + assignedUsers: z.number().optional(), + contains: z.array(z.string()).optional(), + }) ), }); diff --git a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts index a7cf04451a..2075c9e2db 100644 --- a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts @@ -21,6 +21,7 @@ type GroupedAttribute = { id: string; slug: string; value: string; + createdByDSyncId: string | null; }[]; }; @@ -75,17 +76,18 @@ const getByUserIdHandler = async ({ input, ctx }: GetOptions) => { }, }, }, + createdByDSyncId: true, }, }); - const groupedAttributes = userAttributes.reduce((acc, attribute) => { - const { attributeOption } = attribute; + const groupedAttributes = userAttributes.reduce((acc, assignment) => { + const { attributeOption, createdByDSyncId } = assignment; const { attribute: attrInfo, ...optionInfo } = attributeOption; - + const optionInfoWithCreatedByDSyncId = { ...optionInfo, createdByDSyncId }; const existingGroup = acc.find((group) => group.id === attrInfo.id); if (existingGroup) { - existingGroup.options.push(optionInfo); + existingGroup.options.push(optionInfoWithCreatedByDSyncId); } else { acc.push({ id: attrInfo.id, @@ -93,7 +95,7 @@ const getByUserIdHandler = async ({ input, ctx }: GetOptions) => { type: attrInfo.type, options: [ { - ...optionInfo, + ...optionInfoWithCreatedByDSyncId, }, ], }); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 7f6e18253e..fae14baeeb 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -22,6 +22,29 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { const expand = input.expand; const filters = input.filters || []; + const allAttributeOptions = await prisma.attributeOption.findMany({ + where: { + attribute: { + teamId: organizationId, + }, + }, + orderBy: { + attribute: { + name: "asc", + }, + }, + }); + + const groupOptionsWithContainsOptionValues = allAttributeOptions + .filter((option) => option.isGroup) + .map((option) => ({ + ...option, + contains: option.contains.map((optionId) => ({ + id: optionId, + value: allAttributeOptions.find((o) => o.id === optionId)?.value, + })), + })); + if (!organizationId) { throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." }); } @@ -76,13 +99,28 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { break; // We assume that if the filter is not one of the above, it must be an attribute filter default: + const attributeOptionValues: string[] = []; + if (filter.value instanceof Array) { + filter.value.forEach((filterValueItem) => { + attributeOptionValues.push(filterValueItem); + groupOptionsWithContainsOptionValues.forEach((groupOption) => { + if (groupOption.contains.find(({ value: containValue }) => containValue === filterValueItem)) { + attributeOptionValues.push(groupOption.value); + } + }); + }); + } + whereClause.AttributeToUser = { some: { attributeOption: { attribute: { id: filter.id, }, - ...makeWhereClause("value", filter.value), + ...makeWhereClause( + "value", + attributeOptionValues.length > 0 ? attributeOptionValues : filter.value + ), }, }, }; diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 67a81cc365..98c08338ed 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -135,9 +135,7 @@ const workspaces = packagedEmbedTestsOnly test: { globals: true, name: "@calcom/routing-forms", - include: [ - "packages/app-store/routing-forms/**/*.test.tsx", - ], + include: ["packages/app-store/routing-forms/**/*.test.tsx"], environment: "jsdom", setupFiles: ["packages/ui/components/test-setup.ts"], },