diff --git a/packages/app-store/salesforce/.gitignore b/packages/app-store/salesforce/.gitignore index 6c37df8e9e2393..783c5f92c533ba 100644 --- a/packages/app-store/salesforce/.gitignore +++ b/packages/app-store/salesforce/.gitignore @@ -2,4 +2,5 @@ src/gql/fragment-masking.ts # src/gql/gql.ts src/gql/graphql.ts src/gql/index.ts -schema.graphql \ No newline at end of file +schema.graphql +sfdc-package/.sf diff --git a/packages/app-store/salesforce/README.md b/packages/app-store/salesforce/README.md index 1d79b7cb77bd73..acc7e329a09840 100644 --- a/packages/app-store/salesforce/README.md +++ b/packages/app-store/salesforce/README.md @@ -18,3 +18,77 @@ When working with graphql files ensure that `yarn codegen:watch` is running in t The SFDC package is written using Apex. To develop this package, you need to have the Salesforce CLI installed. Then you can run `yarn sfdc:deploy:preview` to see what changes will be deployed to the scratch org. Running `yarn sfdc:deploy` will deploy the changes to the scratch org. Note that if you want to call your local development instances you need to change the "Named Credential" on the scratch org settings to point the `CalCom_Development` credential to the local instance. + +# Publishing the SFDC Package + +All commands should be run from the `sfdc-package` directory: + +```bash +cd packages/app-store/salesforce/sfdc-package +``` + +### Initial Setup (One-time) + +If the package doesn't exist yet in the Dev Hub, create it: + +```bash +sf package create \ + --name "calcom-sfdc-package" \ + --package-type Unlocked \ + --path force-app \ + --target-dev-hub team@cal.com +``` + +This registers the package and updates `sfdx-project.json` with the package ID. + +### Creating a New Package Version + +Each time you want to release changes, create a new version: + +```bash +sf package version create \ + --package "calcom-sfdc-package" \ + --installation-key-bypass \ + --wait 20 \ + --target-dev-hub team@cal.com +``` + +Options: +- `--installation-key-bypass`: Allows installation without a password +- `--wait 20`: Waits up to 20 minutes for completion +- `--code-coverage`: Add this flag when ready to promote (requires 75% Apex test coverage) + +### Viewing Packages and Installation URLs + +List all package versions: + +```bash +sf package version list --target-dev-hub team@cal.com +``` + +The installation URL format is: + +``` +https://login.salesforce.com/packaging/installPackage.apexp?p0=<04t_SUBSCRIBER_PACKAGE_VERSION_ID> +``` + +### Promoting for Production + +Beta versions can only be installed in sandboxes/scratch orgs. To allow installation in production orgs, promote the version: + +```bash +sf package version promote \ + --package "calcom-sfdc-package@X.X.X-X" \ + --target-dev-hub team@cal.com +``` + +Replace `X.X.X-X` with the version number (e.g., `0.1.0-1`). + +### Running Tests + +To run Apex tests and check code coverage: + +```bash +sf project deploy start --target-org +sf apex run test --test-level RunLocalTests --wait 10 --target-org +``` diff --git a/packages/app-store/salesforce/api/user-sync.ts b/packages/app-store/salesforce/api/user-sync.ts index 2f4081bec059b9..59362d44b6ad58 100644 --- a/packages/app-store/salesforce/api/user-sync.ts +++ b/packages/app-store/salesforce/api/user-sync.ts @@ -1,25 +1,143 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - +import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import { getAttributeSyncRuleService } from "@calcom/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.container"; +import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container"; +import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { getAttributeSyncFieldMappingService } from "@calcom/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.container"; const log = logger.getSubLogger({ prefix: ["[salesforce/user-sync]"] }); -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { if (req.method !== "POST") { return res.status(405).json({ error: "Method not allowed" }); } - const { instanceUrl, orgId, salesforceUserId, email, changedFields, timestamp } = req.body; + const { + instanceUrl, + orgId: sfdcOrgId, + salesforceUserId, + email, + changedFields, + timestamp, + } = req.body; log.info("Received user sync request", { instanceUrl, - orgId, + sfdcOrgId, salesforceUserId, + email, + changedFields, timestamp, }); - // TODO: Validate instanceUrl + orgId against stored credentials - // TODO: Sync changedFields to Cal.com user + const credentialRepository = new CredentialRepository(prisma); + const credential = await credentialRepository.findByAppIdAndKeyValue({ + appId: "salesforce", + keyPath: ["instance_url"], + value: instanceUrl, + keyFields: ["id"], + }); + + if (!credential) { + log.error(`No credential found for ${instanceUrl}`); + return res.status(400).json({ error: "Invalid instance URL" }); + } + + if (!credential?.teamId) { + log.error(`Missing teamId for credential ${credential.id}`); + return res.status(400).json({ error: "Invalid credential ID" }); + } + + const salesforceCredentialId = (credential.key as { id?: string } | null)?.id; + + if (!salesforceCredentialId) { + log.error(`Missing SFDC id for credential ${credential.id}`); + return res.status(400).json({ error: "Invalid credential ID" }); + } + + let storedSfdcOrgId: string | undefined; + try { + storedSfdcOrgId = new URL(salesforceCredentialId).pathname.split("/")[2]; + } catch { + log.error(`Invalid SFDC credential URL format for credential ${credential.id}`); + return res.status(400).json({ error: "Invalid credential format" }); + } + + if (storedSfdcOrgId !== sfdcOrgId) { + log.error(`Mismatched orgId ${sfdcOrgId} for credential ${credential.id}`); + return res.status(400).json({ error: "Invalid org ID" }); + } + + const userRepository = new UserRepository(prisma); + const user = await userRepository.findByEmailAndTeamId({ + email, + teamId: credential.teamId, + }); + + if (!user) { + log.error( + `User not found for email ${email} and teamId ${credential.teamId}` + ); + return res.status(400).json({ error: "Invalid user" }); + } + + const organizationId = credential.teamId; + + const integrationAttributeSyncService = getIntegrationAttributeSyncService(); + + const integrationAttributeSyncs = + await integrationAttributeSyncService.getAllByCredentialId(credential.id); + + const attributeSyncRuleService = getAttributeSyncRuleService(); + const attributeSyncFieldMappingService = + getAttributeSyncFieldMappingService(); + + const results = await Promise.allSettled( + integrationAttributeSyncs.map(async (sync) => { + // Only check rule if one exists - skip sync only if rule returns false + if (sync.attributeSyncRule) { + const shouldSyncApplyToUser = + await attributeSyncRuleService.shouldSyncApplyToUser({ + user: { + id: user.id, + organizationId, + }, + attributeSyncRule: sync.attributeSyncRule.rule, + }); + + if (!shouldSyncApplyToUser) return; + } + + // Salesforce multi-select picklists use `;` as separator, convert to `,` for the service + const integrationFields = Object.fromEntries( + Object.entries(changedFields as Record) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value).replaceAll(";", ",")]) + ); + + await attributeSyncFieldMappingService.syncIntegrationFieldsToAttributes({ + userId: user.id, + organizationId, + syncFieldMappings: sync.syncFieldMappings, + integrationFields, + }); + }) + ); + + const errors = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + + if (errors.length > 0) { + log.error("Errors syncing user attributes", { + errors: errors.map((e) => e.reason), + }); + } return res.status(200).json({ success: true }); } diff --git a/packages/app-store/salesforce/sfdc-package/.sfdx/sfdx-config.json b/packages/app-store/salesforce/sfdc-package/.sfdx/sfdx-config.json new file mode 100644 index 00000000000000..8c1206e5e3feb0 --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/.sfdx/sfdx-config.json @@ -0,0 +1,3 @@ +{ + "defaultdevhubusername": "DevHub" +} \ No newline at end of file diff --git a/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls new file mode 100644 index 00000000000000..2d9c142f251e54 --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls @@ -0,0 +1,108 @@ +@isTest +private class CalComCalloutQueueableTest { + + @isTest + static void testQueueableExecutionSuccess() { + CalComHttpMock.resetState(); + + List payloads = createTestPayloads(1); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock(200, '{"success": true}')); + + Test.startTest(); + CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads); + System.enqueueJob(queueable); + Test.stopTest(); + + System.assertEquals(1, CalComHttpMock.getCallCount(), 'Expected exactly one HTTP callout'); + List requests = CalComHttpMock.getCapturedRequests(); + System.assertEquals(1, requests.size(), 'Expected one captured request'); + System.assertEquals('POST', requests[0].getMethod(), 'Request method should be POST'); + } + + @isTest + static void testQueueableExecutionWithMultiplePayloads() { + CalComHttpMock.resetState(); + + List payloads = createTestPayloads(3); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock(200, '{"success": true}')); + + Test.startTest(); + CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads); + System.enqueueJob(queueable); + Test.stopTest(); + + System.assertEquals(3, CalComHttpMock.getCallCount(), 'Expected three HTTP callouts for three payloads'); + } + + @isTest + static void testQueueableExecutionFailureResponse() { + CalComHttpMock.resetState(); + + List payloads = createTestPayloads(1); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock(500, '{"error": "Internal Server Error"}')); + + Test.startTest(); + CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads); + System.enqueueJob(queueable); + Test.stopTest(); + + System.assertEquals(1, CalComHttpMock.getCallCount(), 'Expected HTTP callout even for failure response'); + } + + @isTest + static void testQueueableExecutionEmptyPayloads() { + CalComHttpMock.resetState(); + + List payloads = new List(); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock()); + + Test.startTest(); + CalComCalloutQueueable queueable = new CalComCalloutQueueable(payloads); + System.enqueueJob(queueable); + Test.stopTest(); + + System.assertEquals(0, CalComHttpMock.getCallCount(), 'Expected no HTTP callouts with empty payloads'); + } + + @isTest + static void testUserChangePayloadStructure() { + CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload(); + payload.salesforceUserId = UserInfo.getUserId(); + payload.email = 'test@example.com'; + payload.instanceUrl = 'https://test.salesforce.com'; + payload.orgId = UserInfo.getOrganizationId(); + payload.changedFields = new Map{'FirstName' => 'Test'}; + payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\''); + + System.assertNotEquals(null, payload.salesforceUserId, 'salesforceUserId should be set'); + System.assertEquals('test@example.com', payload.email, 'email should match'); + System.assertNotEquals(null, payload.instanceUrl, 'instanceUrl should be set'); + System.assertNotEquals(null, payload.orgId, 'orgId should be set'); + System.assertEquals(1, payload.changedFields.size(), 'changedFields should have one entry'); + System.assertNotEquals(null, payload.timestamp, 'timestamp should be set'); + } + + private static List createTestPayloads(Integer count) { + List payloads = new List(); + + for (Integer i = 0; i < count; i++) { + CalComCalloutQueueable.UserChangePayload payload = new CalComCalloutQueueable.UserChangePayload(); + payload.salesforceUserId = UserInfo.getUserId(); + payload.email = 'test' + i + '@example.com'; + payload.instanceUrl = URL.getOrgDomainUrl().toExternalForm(); + payload.orgId = UserInfo.getOrganizationId(); + payload.changedFields = new Map{ + 'FirstName' => 'TestFirst' + i, + 'LastName' => 'TestLast' + i + }; + payload.timestamp = Datetime.now().formatGMT('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\''); + payloads.add(payload); + } + + return payloads; + } +} diff --git a/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls-meta.xml b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls-meta.xml new file mode 100644 index 00000000000000..1e7de940889214 --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComCalloutQueueableTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + diff --git a/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComHttpMock.cls b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComHttpMock.cls new file mode 100644 index 00000000000000..9f75e40d9a7fea --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComHttpMock.cls @@ -0,0 +1,43 @@ +@isTest +public class CalComHttpMock implements HttpCalloutMock { + + private Integer statusCode; + private String responseBody; + + private static Integer callCount = 0; + private static List capturedRequests = new List(); + + public CalComHttpMock(Integer statusCode, String responseBody) { + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + public CalComHttpMock() { + this.statusCode = 200; + this.responseBody = '{"success": true}'; + } + + public HttpResponse respond(HttpRequest req) { + callCount++; + capturedRequests.add(req); + + HttpResponse res = new HttpResponse(); + res.setStatusCode(this.statusCode); + res.setBody(this.responseBody); + res.setHeader('Content-Type', 'application/json'); + return res; + } + + public static Integer getCallCount() { + return callCount; + } + + public static List getCapturedRequests() { + return capturedRequests; + } + + public static void resetState() { + callCount = 0; + capturedRequests = new List(); + } +} diff --git a/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComHttpMock.cls-meta.xml b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComHttpMock.cls-meta.xml new file mode 100644 index 00000000000000..1e7de940889214 --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/CalComHttpMock.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + diff --git a/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/UserUpdateHandlerTest.cls b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/UserUpdateHandlerTest.cls new file mode 100644 index 00000000000000..b5e59db3ec0d0c --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/UserUpdateHandlerTest.cls @@ -0,0 +1,140 @@ +@isTest +private class UserUpdateHandlerTest { + + @isTest + static void testHandleAfterUpdateWithChangedFields() { + CalComHttpMock.resetState(); + + User testUser = [SELECT Id, FirstName, LastName, Email FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + + Map oldMap = new Map(); + Map newMap = new Map(); + + User oldUser = testUser.clone(true, true, true, true); + oldUser.FirstName = 'OldFirstName'; + + User newUser = testUser.clone(true, true, true, true); + newUser.FirstName = 'NewFirstName'; + + oldMap.put(testUser.Id, oldUser); + newMap.put(testUser.Id, newUser); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock()); + + Test.startTest(); + UserUpdateHandler.handleAfterUpdate(oldMap, newMap); + Test.stopTest(); + + System.assertEquals(1, CalComHttpMock.getCallCount(), 'Expected exactly one HTTP callout for changed fields'); + List requests = CalComHttpMock.getCapturedRequests(); + System.assertEquals(1, requests.size(), 'Expected one captured request'); + System.assert(requests[0].getBody().contains('changedFields'), 'Request body should contain changedFields'); + } + + @isTest + static void testHandleAfterUpdateWithNoChanges() { + CalComHttpMock.resetState(); + + User testUser = [SELECT Id, FirstName, LastName, Email FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + + Map oldMap = new Map(); + Map newMap = new Map(); + + oldMap.put(testUser.Id, testUser); + newMap.put(testUser.Id, testUser); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock()); + + Test.startTest(); + UserUpdateHandler.handleAfterUpdate(oldMap, newMap); + Test.stopTest(); + + System.assertEquals(0, CalComHttpMock.getCallCount(), 'Expected no HTTP callouts when no fields changed'); + } + + @isTest + static void testHandleAfterUpdateWithMultipleUsers() { + CalComHttpMock.resetState(); + + List testUsers = [SELECT Id, FirstName, LastName, Email FROM User WHERE IsActive = true LIMIT 2]; + + if (testUsers.size() < 2) { + System.assert(true, 'Not enough users to test multiple user scenario'); + return; + } + + Map oldMap = new Map(); + Map newMap = new Map(); + + for (User u : testUsers) { + User oldUser = u.clone(true, true, true, true); + oldUser.FirstName = 'Old' + u.FirstName; + + User newUser = u.clone(true, true, true, true); + newUser.FirstName = 'New' + u.FirstName; + + oldMap.put(u.Id, oldUser); + newMap.put(u.Id, newUser); + } + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock()); + + Test.startTest(); + UserUpdateHandler.handleAfterUpdate(oldMap, newMap); + Test.stopTest(); + + System.assertEquals(testUsers.size(), CalComHttpMock.getCallCount(), 'Expected HTTP callouts for each user with changed fields'); + } + + @isTest + static void testHandleAfterUpdateWithMultipleFieldChanges() { + CalComHttpMock.resetState(); + + User testUser = [SELECT Id, FirstName, LastName, Email, Title, Department FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + + Map oldMap = new Map(); + Map newMap = new Map(); + + User oldUser = testUser.clone(true, true, true, true); + oldUser.FirstName = 'OldFirst'; + oldUser.LastName = 'OldLast'; + oldUser.Title = 'Old Title'; + + User newUser = testUser.clone(true, true, true, true); + newUser.FirstName = 'NewFirst'; + newUser.LastName = 'NewLast'; + newUser.Title = 'New Title'; + + oldMap.put(testUser.Id, oldUser); + newMap.put(testUser.Id, newUser); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock()); + + Test.startTest(); + UserUpdateHandler.handleAfterUpdate(oldMap, newMap); + Test.stopTest(); + + System.assertEquals(1, CalComHttpMock.getCallCount(), 'Expected exactly one HTTP callout for multiple field changes'); + List requests = CalComHttpMock.getCapturedRequests(); + System.assertEquals(1, requests.size(), 'Expected one captured request'); + String requestBody = requests[0].getBody(); + System.assert(requestBody.contains('FirstName'), 'Request body should contain FirstName field'); + System.assert(requestBody.contains('LastName'), 'Request body should contain LastName field'); + } + + @isTest + static void testHandleAfterUpdateWithEmptyMaps() { + CalComHttpMock.resetState(); + + Map oldMap = new Map(); + Map newMap = new Map(); + + Test.setMock(HttpCalloutMock.class, new CalComHttpMock()); + + Test.startTest(); + UserUpdateHandler.handleAfterUpdate(oldMap, newMap); + Test.stopTest(); + + System.assertEquals(0, CalComHttpMock.getCallCount(), 'Expected no HTTP callouts with empty maps'); + } +} diff --git a/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/UserUpdateHandlerTest.cls-meta.xml b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/UserUpdateHandlerTest.cls-meta.xml new file mode 100644 index 00000000000000..1e7de940889214 --- /dev/null +++ b/packages/app-store/salesforce/sfdc-package/force-app/main/default/classes/UserUpdateHandlerTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + Active + diff --git a/packages/app-store/salesforce/sfdc-package/sfdx-project.json b/packages/app-store/salesforce/sfdc-package/sfdx-project.json index 591afc134d99ea..cafb085e87d674 100644 --- a/packages/app-store/salesforce/sfdc-package/sfdx-project.json +++ b/packages/app-store/salesforce/sfdc-package/sfdx-project.json @@ -1,12 +1,20 @@ { "packageDirectories": [ { + "versionName": "ver 0.1", + "versionNumber": "0.1.0.NEXT", "path": "force-app", - "default": true + "default": true, + "package": "calcom-sfdc-package", + "versionDescription": "" } ], - "name": "calcom-user-sync", + "name": "calcom-sfdc-package", "namespace": "", "sfdcLoginUrl": "https://login.salesforce.com", - "sourceApiVersion": "64.0" -} + "sourceApiVersion": "64.0", + "packageAliases": { + "calcom-sfdc-package": "0HoKk0000004C9cKAE", + "calcom-sfdc-package@0.1.0-1": "04tKk0000000SwIIAU" + } +} \ No newline at end of file diff --git a/packages/features/attributes/di/AttributeService.container.ts b/packages/features/attributes/di/AttributeService.container.ts new file mode 100644 index 00000000000000..09bee93f568ab8 --- /dev/null +++ b/packages/features/attributes/di/AttributeService.container.ts @@ -0,0 +1,14 @@ +import { createContainer } from "@calcom/features/di/di"; + +import { + type AttributeService, + moduleLoader as attributeServiceModule, +} from "./AttributeService.module"; + +const attributeServiceContainer = createContainer(); + +export function getAttributeService(): AttributeService { + attributeServiceModule.loadModule(attributeServiceContainer); + + return attributeServiceContainer.get(attributeServiceModule.token); +} diff --git a/packages/features/attributes/di/AttributeService.module.ts b/packages/features/attributes/di/AttributeService.module.ts new file mode 100644 index 00000000000000..81a38cf182615b --- /dev/null +++ b/packages/features/attributes/di/AttributeService.module.ts @@ -0,0 +1,29 @@ +import { + bindModuleToClassOnToken, + createModule, + type ModuleLoader, +} from "@calcom/features/di/di"; + +import { AttributeService } from "../services/AttributeService"; +import { moduleLoader as attributeToUserRepositoryModuleLoader } from "./AttributeToUserRepository.module"; +import { ATTRIBUTE_DI_TOKENS } from "./tokens"; + +export const attributeServiceModule = createModule(); +const token = ATTRIBUTE_DI_TOKENS.ATTRIBUTE_SERVICE; +const moduleToken = ATTRIBUTE_DI_TOKENS.ATTRIBUTE_SERVICE_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeServiceModule, + moduleToken, + token, + classs: AttributeService, + depsMap: { + attributeToUserRepository: attributeToUserRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { AttributeService }; diff --git a/packages/features/attributes/di/AttributeToUserRepository.module.ts b/packages/features/attributes/di/AttributeToUserRepository.module.ts new file mode 100644 index 00000000000000..395dadc720744a --- /dev/null +++ b/packages/features/attributes/di/AttributeToUserRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { PrismaAttributeToUserRepository } from "../repositories/PrismaAttributeToUserRepository"; +import { ATTRIBUTE_DI_TOKENS } from "./tokens"; + +export const attributeToUserRepositoryModule = createModule(); +const token = ATTRIBUTE_DI_TOKENS.ATTRIBUTE_TO_USER_REPOSITORY; +const moduleToken = ATTRIBUTE_DI_TOKENS.ATTRIBUTE_TO_USER_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeToUserRepositoryModule, + moduleToken, + token, + classs: PrismaAttributeToUserRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { PrismaAttributeToUserRepository }; diff --git a/packages/features/attributes/di/tokens.ts b/packages/features/attributes/di/tokens.ts new file mode 100644 index 00000000000000..333dcf40b4515a --- /dev/null +++ b/packages/features/attributes/di/tokens.ts @@ -0,0 +1,6 @@ +export const ATTRIBUTE_DI_TOKENS = { + ATTRIBUTE_SERVICE: Symbol("AttributeService"), + ATTRIBUTE_SERVICE_MODULE: Symbol("AttributeServiceModule"), + ATTRIBUTE_TO_USER_REPOSITORY: Symbol("AttributeToUserRepository"), + ATTRIBUTE_TO_USER_REPOSITORY_MODULE: Symbol("AttributeToUserRepositoryModule"), +}; diff --git a/packages/features/attributes/repositories/PrismaAttributeOptionRepository.ts b/packages/features/attributes/repositories/PrismaAttributeOptionRepository.ts index f9667dcbf46f8e..da6b973e9210a8 100644 --- a/packages/features/attributes/repositories/PrismaAttributeOptionRepository.ts +++ b/packages/features/attributes/repositories/PrismaAttributeOptionRepository.ts @@ -1,9 +1,11 @@ -import { prisma } from "@calcom/prisma"; +import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; export class PrismaAttributeOptionRepository { - static async findMany({ orgId }: { orgId: number }) { - return prisma.attributeOption.findMany({ + constructor(private prismaClient: PrismaClient) {} + + async findMany({ orgId }: { orgId: number }) { + return this.prismaClient.attributeOption.findMany({ where: { attribute: { teamId: orgId, @@ -18,8 +20,8 @@ export class PrismaAttributeOptionRepository { }); } - static async createMany({ createManyInput }: { createManyInput: Prisma.AttributeOptionCreateManyInput[] }) { - const { count } = await prisma.attributeOption.createMany({ + async createMany({ createManyInput }: { createManyInput: Prisma.AttributeOptionCreateManyInput[] }) { + const { count } = await this.prismaClient.attributeOption.createMany({ data: createManyInput, }); diff --git a/packages/features/attributes/repositories/PrismaAttributeRepository.ts b/packages/features/attributes/repositories/PrismaAttributeRepository.ts index d7684f65d0df27..aad5a1c9aed84d 100644 --- a/packages/features/attributes/repositories/PrismaAttributeRepository.ts +++ b/packages/features/attributes/repositories/PrismaAttributeRepository.ts @@ -96,4 +96,30 @@ export class PrismaAttributeRepository { }, }); } + + findManyByIdsAndOrgIdWithOptions({ + attributeIds, + orgId, + }: { + attributeIds: string[]; + orgId: number; + }) { + return this.prismaClient.attribute.findMany({ + where: { + teamId: orgId, + id: { + in: attributeIds, + }, + }, + include: { + options: { + select: { + id: true, + value: true, + slug: true, + }, + }, + }, + }); + } } diff --git a/packages/features/attributes/services/AttributeService.ts b/packages/features/attributes/services/AttributeService.ts new file mode 100644 index 00000000000000..5f06c26dd189db --- /dev/null +++ b/packages/features/attributes/services/AttributeService.ts @@ -0,0 +1,68 @@ +import type { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; +import { AttributeType } from "@calcom/prisma/enums"; + +interface IAttributeServiceDeps { + attributeToUserRepository: PrismaAttributeToUserRepository; +} + +type MultiSelectAttribute = { + type: "MULTI_SELECT"; + optionIds: Set; + values: Set; +}; + +type SingleValueAttribute = { + type: "TEXT" | "NUMBER" | "SINGLE_SELECT"; + optionId: string | null; + value: string | null; +}; + +export type UserAttribute = MultiSelectAttribute | SingleValueAttribute; + +export class AttributeService { + constructor(private readonly deps: IAttributeServiceDeps) {} + + /** Grouped by attribute */ + async getUsersAttributesByOrgMembershipId({ + userId, + orgId, + }: { + userId: number; + orgId: number; + }): Promise> { + const attributeOptionsAssignedToUser = + await this.deps.attributeToUserRepository.findManyIncludeAttribute({ + member: { userId, teamId: orgId }, + }); + + const userAttributes: Record = {}; + + for (const assignedAttribute of attributeOptionsAssignedToUser) { + const attribute = assignedAttribute.attributeOption.attribute; + const attributeOptionId = assignedAttribute.attributeOptionId; + const attributeValue = assignedAttribute.attributeOption.value; + + if (attribute.type === AttributeType.MULTI_SELECT) { + if (attribute.id in userAttributes) { + const existing = userAttributes[attribute.id] as MultiSelectAttribute; + existing.optionIds.add(attributeOptionId); + existing.values.add(attributeValue); + } else { + userAttributes[attribute.id] = { + type: "MULTI_SELECT", + optionIds: new Set([attributeOptionId]), + values: new Set([attributeValue]), + }; + } + } else { + // For TEXT, NUMBER, SINGLE_SELECT - only store single value + userAttributes[attribute.id] = { + type: attribute.type as "TEXT" | "NUMBER" | "SINGLE_SELECT", + optionId: attributeOptionId, + value: attributeValue, + }; + } + } + return userAttributes; + } +} diff --git a/packages/features/attributes/services/attributeService.test.ts b/packages/features/attributes/services/attributeService.test.ts new file mode 100644 index 00000000000000..a0fb5fa7408d9c --- /dev/null +++ b/packages/features/attributes/services/attributeService.test.ts @@ -0,0 +1,318 @@ +import type { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { AttributeService } from "./AttributeService"; + +describe("AttributeService", () => { + let service: AttributeService; + let mockAttributeToUserRepository: { + findManyIncludeAttribute: ReturnType; + }; + + beforeEach(() => { + vi.resetAllMocks(); + + mockAttributeToUserRepository = { + findManyIncludeAttribute: vi.fn(), + }; + + service = new AttributeService({ + attributeToUserRepository: + mockAttributeToUserRepository as unknown as PrismaAttributeToUserRepository, + }); + }); + + describe("getUsersAttributesByOrgMembershipId", () => { + it("should return empty object when user has no attributes", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue( + [] + ); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({}); + expect( + mockAttributeToUserRepository.findManyIncludeAttribute + ).toHaveBeenCalledWith({ + member: { userId: 1, teamId: 100 }, + }); + }); + + it("should return SINGLE_SELECT attribute correctly", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "Engineering", + attribute: { + id: "attr-1", + type: "SINGLE_SELECT", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "opt-1", + value: "Engineering", + }, + }); + }); + + it("should return TEXT attribute correctly", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "Senior Engineer", + attribute: { + id: "attr-1", + type: "TEXT", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({ + "attr-1": { + type: "TEXT", + optionId: "opt-1", + value: "Senior Engineer", + }, + }); + }); + + it("should return NUMBER attribute correctly", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "42", + attribute: { + id: "attr-1", + type: "NUMBER", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({ + "attr-1": { + type: "NUMBER", + optionId: "opt-1", + value: "42", + }, + }); + }); + + it("should return MULTI_SELECT attribute with multiple options correctly", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "JavaScript", + attribute: { + id: "attr-1", + type: "MULTI_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-2", + attributeOption: { + value: "TypeScript", + attribute: { + id: "attr-1", + type: "MULTI_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-3", + attributeOption: { + value: "Python", + attribute: { + id: "attr-1", + type: "MULTI_SELECT", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({ + "attr-1": { + type: "MULTI_SELECT", + optionIds: new Set(["opt-1", "opt-2", "opt-3"]), + values: new Set(["JavaScript", "TypeScript", "Python"]), + }, + }); + }); + + it("should handle multiple attributes of different types", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "Engineering", + attribute: { + id: "attr-1", + type: "SINGLE_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-2", + attributeOption: { + value: "JavaScript", + attribute: { + id: "attr-2", + type: "MULTI_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-3", + attributeOption: { + value: "TypeScript", + attribute: { + id: "attr-2", + type: "MULTI_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-4", + attributeOption: { + value: "Senior Engineer", + attribute: { + id: "attr-3", + type: "TEXT", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "opt-1", + value: "Engineering", + }, + "attr-2": { + type: "MULTI_SELECT", + optionIds: new Set(["opt-2", "opt-3"]), + values: new Set(["JavaScript", "TypeScript"]), + }, + "attr-3": { + type: "TEXT", + optionId: "opt-4", + value: "Senior Engineer", + }, + }); + }); + + it("should overwrite SINGLE_SELECT attribute if multiple values exist (last one wins)", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "Engineering", + attribute: { + id: "attr-1", + type: "SINGLE_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-2", + attributeOption: { + value: "Sales", + attribute: { + id: "attr-1", + type: "SINGLE_SELECT", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result).toEqual({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "opt-2", + value: "Sales", + }, + }); + }); + + it("should accumulate MULTI_SELECT options correctly", async () => { + mockAttributeToUserRepository.findManyIncludeAttribute.mockResolvedValue([ + { + attributeOptionId: "opt-1", + attributeOption: { + value: "Skill A", + attribute: { + id: "attr-1", + type: "MULTI_SELECT", + }, + }, + }, + { + attributeOptionId: "opt-1", + attributeOption: { + value: "Skill A", + attribute: { + id: "attr-1", + type: "MULTI_SELECT", + }, + }, + }, + ]); + + const result = await service.getUsersAttributesByOrgMembershipId({ + userId: 1, + orgId: 100, + }); + + expect(result["attr-1"]).toEqual({ + type: "MULTI_SELECT", + optionIds: new Set(["opt-1"]), + values: new Set(["Skill A"]), + }); + }); + }); +}); diff --git a/packages/features/credentials/repositories/CredentialRepository.ts b/packages/features/credentials/repositories/CredentialRepository.ts index ed06ee2e8cc90a..20b445f538155a 100644 --- a/packages/features/credentials/repositories/CredentialRepository.ts +++ b/packages/features/credentials/repositories/CredentialRepository.ts @@ -25,19 +25,22 @@ type CredentialUpdateInput = { }; export class CredentialRepository { - constructor(private primaClient: PrismaClient) { } + constructor(private prismaClient: PrismaClient) {} async findByCredentialId(id: number) { - return this.primaClient.credential.findUnique({ + return this.prismaClient.credential.findUnique({ where: { id }, select: safeCredentialSelect, }); } async findByIdWithDelegationCredential(id: number) { - return this.primaClient.credential.findUnique({ + return this.prismaClient.credential.findUnique({ where: { id }, - select: { ...credentialForCalendarServiceSelect, delegationCredential: true }, + select: { + ...credentialForCalendarServiceSelect, + delegationCredential: true, + }, }); } @@ -45,7 +48,13 @@ export class CredentialRepository { const credential = await prisma.credential.create({ data: { ...data } }); return buildNonDelegationCredential(credential); } - static async findByAppIdAndUserId({ appId, userId }: { appId: string; userId: number }) { + static async findByAppIdAndUserId({ + appId, + userId, + }: { + appId: string; + userId: number; + }) { const credential = await prisma.credential.findFirst({ where: { appId, @@ -59,7 +68,10 @@ export class CredentialRepository { * Doesn't retrieve key field as that has credentials */ static async findFirstByIdWithUser({ id }: { id: number }) { - const credential = await prisma.credential.findUnique({ where: { id }, select: safeCredentialSelect }); + const credential = await prisma.credential.findUnique({ + where: { id }, + select: safeCredentialSelect, + }); return buildNonDelegationCredential(credential); } @@ -74,7 +86,13 @@ export class CredentialRepository { return buildNonDelegationCredential(credential); } - static async findFirstByAppIdAndUserId({ appId, userId }: { appId: string; userId: number }) { + static async findFirstByAppIdAndUserId({ + appId, + userId, + }: { + appId: string; + userId: number; + }) { return await prisma.credential.findFirst({ where: { appId, @@ -83,8 +101,16 @@ export class CredentialRepository { }); } - static async findFirstByUserIdAndType({ userId, type }: { userId: number; type: string }) { - const credential = await prisma.credential.findFirst({ where: { userId, type } }); + static async findFirstByUserIdAndType({ + userId, + type, + }: { + userId: number; + type: string; + }) { + const credential = await prisma.credential.findFirst({ + where: { userId, type }, + }); return buildNonDelegationCredential(credential); } @@ -92,7 +118,13 @@ export class CredentialRepository { await prisma.credential.delete({ where: { id } }); } - static async updateCredentialById({ id, data }: { id: number; data: CredentialUpdateInput }) { + static async updateCredentialById({ + id, + data, + }: { + id: number; + data: CredentialUpdateInput; + }) { await prisma.credential.update({ where: { id }, data, @@ -123,7 +155,10 @@ export class CredentialRepository { static async findByIdIncludeDelegationCredential({ id }: { id: number }) { const dbCredential = await prisma.credential.findUnique({ where: { id }, - select: { ...credentialForCalendarServiceSelect, delegationCredential: true }, + select: { + ...credentialForCalendarServiceSelect, + delegationCredential: true, + }, }); return dbCredential; @@ -152,7 +187,13 @@ export class CredentialRepository { }); } - static async findAllDelegationByTypeIncludeUserAndTake({ type, take }: { type: string; take: number }) { + static async findAllDelegationByTypeIncludeUserAndTake({ + type, + take, + }: { + type: string; + take: number; + }) { const delegationUserCredentials = await prisma.credential.findMany({ where: { delegationCredentialId: { not: null }, @@ -168,14 +209,16 @@ export class CredentialRepository { }, take, }); - return delegationUserCredentials.map(({ delegationCredentialId, ...rest }) => { - return { - ...rest, - // We queried only those where delegationCredentialId is not null - - delegationCredentialId: delegationCredentialId!, - }; - }); + return delegationUserCredentials.map( + ({ delegationCredentialId, ...rest }) => { + return { + ...rest, + // We queried only those where delegationCredentialId is not null + + delegationCredentialId: delegationCredentialId!, + }; + } + ); } static async findUniqueByUserIdAndDelegationCredentialId({ @@ -195,10 +238,13 @@ export class CredentialRepository { if (delegationUserCredentials.length > 1) { // Instead of crashing use the first one and log for observability // TODO: Plan to add a unique constraint on userId and delegationCredentialId - log.error(`DelegationCredential: Multiple delegation user credentials found - this should not happen`, { - userId, - delegationCredentialId, - }); + log.error( + `DelegationCredential: Multiple delegation user credentials found - this should not happen`, + { + userId, + delegationCredentialId, + } + ); } return delegationUserCredentials[0]; @@ -237,10 +283,18 @@ export class CredentialRepository { key: Prisma.InputJsonValue; appId: string; }) { - return prisma.credential.create({ data: { userId, delegationCredentialId, type, key, appId } }); + return prisma.credential.create({ + data: { userId, delegationCredentialId, type, key, appId }, + }); } - static async updateWhereId({ id, data }: { id: number; data: { key: Prisma.InputJsonValue } }) { + static async updateWhereId({ + id, + data, + }: { + id: number; + data: { key: Prisma.InputJsonValue }; + }) { return prisma.credential.update({ where: { id }, data }); } @@ -302,7 +356,7 @@ export class CredentialRepository { } findByTeamIdAndSlugs({ teamId, slugs }: { teamId: number; slugs: string[] }) { - return this.primaClient.credential.findMany({ + return this.prismaClient.credential.findMany({ where: { teamId, appId: { @@ -314,7 +368,7 @@ export class CredentialRepository { } findByIdAndTeamId({ id, teamId }: { id: number; teamId: number }) { - return this.primaClient.credential.findFirst({ + return this.prismaClient.credential.findFirst({ where: { id, teamId, @@ -329,4 +383,63 @@ export class CredentialRepository { }, }); } + + async findByAppIdAndKeyValue({ + appId, + keyPath, + value, + keyFields, + }: { + appId: string; + keyPath: string[]; + value: Prisma.InputJsonValue; + keyFields?: string[]; + }) { + const credential = await this.prismaClient.credential.findFirst({ + where: { + appId, + key: { + path: keyPath, + equals: value, + }, + }, + select: { + ...safeCredentialSelect, + integrationAttributeSyncs: { + select: { + id: true, + attributeSyncRule: { + select: { + id: true, + rule: true, + }, + }, + syncFieldMappings: { + select: { + id: true, + integrationFieldName: true, + attributeId: true, + enabled: true, + }, + }, + }, + }, + key: keyFields ? true : false, + }, + }); + + if (!credential || !keyFields) { + return credential; + } + + const key = credential.key as Record; + const filteredKey = keyFields.reduce((acc, field) => { + if (field in key) { + acc[field] = key[field]; + } + return acc; + }, {} as Record); + + return { ...credential, key: filteredKey }; + } } diff --git a/packages/features/ee/dsync/lib/assignValueToUser.ts b/packages/features/ee/dsync/lib/assignValueToUser.ts index 6fe52ebf90b10a..4b4fbaa9f13fa8 100644 --- a/packages/features/ee/dsync/lib/assignValueToUser.ts +++ b/packages/features/ee/dsync/lib/assignValueToUser.ts @@ -331,13 +331,15 @@ const createMissingOptionsAndReturnAlongWithExisting = async < ), }); - await PrismaAttributeOptionRepository.createMany({ + const attributeOptionRepository = new PrismaAttributeOptionRepository(prisma); + + 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 PrismaAttributeOptionRepository.findMany({ + await attributeOptionRepository.findMany({ orgId, }) ).map((attributeOption) => ({ diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeOptionRepository.module.ts b/packages/features/ee/integration-attribute-sync/di/AttributeOptionRepository.module.ts new file mode 100644 index 00000000000000..4b450400c454cc --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeOptionRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { PrismaAttributeOptionRepository } from "@calcom/features/attributes/repositories/PrismaAttributeOptionRepository"; + +import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens"; + +export const attributeOptionRepositoryModule = createModule(); +const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_OPTION_REPOSITORY; +const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_OPTION_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeOptionRepositoryModule, + moduleToken, + token, + classs: PrismaAttributeOptionRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { PrismaAttributeOptionRepository }; diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeRepository.module.ts b/packages/features/ee/integration-attribute-sync/di/AttributeRepository.module.ts new file mode 100644 index 00000000000000..c0ae135eaf89c6 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository"; + +import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens"; + +export const attributeRepositoryModule = createModule(); +const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_REPOSITORY; +const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeRepositoryModule, + moduleToken, + token, + classs: PrismaAttributeRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { PrismaAttributeRepository }; diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.container.ts b/packages/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.container.ts new file mode 100644 index 00000000000000..0b8fe135d815a5 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.container.ts @@ -0,0 +1,16 @@ +import { createContainer } from "@calcom/features/di/di"; + +import { + type AttributeSyncFieldMappingService, + moduleLoader as attributeSyncFieldMappingServiceModule, +} from "./AttributeSyncFieldMappingService.module"; + +const attributeSyncFieldMappingServiceContainer = createContainer(); + +export function getAttributeSyncFieldMappingService(): AttributeSyncFieldMappingService { + attributeSyncFieldMappingServiceModule.loadModule(attributeSyncFieldMappingServiceContainer); + + return attributeSyncFieldMappingServiceContainer.get( + attributeSyncFieldMappingServiceModule.token + ); +} diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.module.ts b/packages/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.module.ts new file mode 100644 index 00000000000000..7ea1f453fab7bc --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeSyncFieldMappingService.module.ts @@ -0,0 +1,31 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as membershipRepositoryModuleLoader } from "@calcom/features/users/di/MembershipRepository.module"; + +import { AttributeSyncFieldMappingService } from "../services/AttributeSyncFieldMappingService"; +import { moduleLoader as attributeToUserRepositoryModuleLoader } from "./AttributeToUserRepository.module"; +import { moduleLoader as attributeRepositoryModuleLoader } from "./AttributeRepository.module"; +import { moduleLoader as attributeOptionRepositoryModuleLoader } from "./AttributeOptionRepository.module"; +import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens"; + +export const attributeSyncFieldMappingServiceModule = createModule(); +const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_SYNC_FIELD_MAPPING_SERVICE; +const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_SYNC_FIELD_MAPPING_SERVICE_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeSyncFieldMappingServiceModule, + moduleToken, + token, + classs: AttributeSyncFieldMappingService, + depsMap: { + attributeToUserRepository: attributeToUserRepositoryModuleLoader, + attributeRepository: attributeRepositoryModuleLoader, + attributeOptionRepository: attributeOptionRepositoryModuleLoader, + membershipRepository: membershipRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { AttributeSyncFieldMappingService }; diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.container.ts b/packages/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.container.ts new file mode 100644 index 00000000000000..c123000ccc5050 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.container.ts @@ -0,0 +1,14 @@ +import { createContainer } from "@calcom/features/di/di"; + +import { + type AttributeSyncRuleService, + moduleLoader as attributeSyncRuleServiceModule, +} from "./AttributeSyncRuleService.module"; + +const attributeSyncRuleServiceContainer = createContainer(); + +export function getAttributeSyncRuleService(): AttributeSyncRuleService { + attributeSyncRuleServiceModule.loadModule(attributeSyncRuleServiceContainer); + + return attributeSyncRuleServiceContainer.get(attributeSyncRuleServiceModule.token); +} diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.module.ts b/packages/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.module.ts new file mode 100644 index 00000000000000..2658b14dd31576 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeSyncRuleService.module.ts @@ -0,0 +1,27 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as membershipRepositoryModuleLoader } from "@calcom/features/users/di/MembershipRepository.module"; + +import { AttributeSyncRuleService } from "../services/AttributeSyncRuleService"; +import { moduleLoader as attributeToUserRepositoryModuleLoader } from "./AttributeToUserRepository.module"; +import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens"; + +export const attributeSyncRuleServiceModule = createModule(); +const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_SYNC_RULE_SERVICE; +const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_SYNC_RULE_SERVICE_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeSyncRuleServiceModule, + moduleToken, + token, + classs: AttributeSyncRuleService, + depsMap: { + membershipRepository: membershipRepositoryModuleLoader, + attributeToUserRepository: attributeToUserRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { AttributeSyncRuleService }; diff --git a/packages/features/ee/integration-attribute-sync/di/AttributeToUserRepository.module.ts b/packages/features/ee/integration-attribute-sync/di/AttributeToUserRepository.module.ts new file mode 100644 index 00000000000000..b8a7949a81b1f7 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/di/AttributeToUserRepository.module.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; + +import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens"; + +export const attributeToUserRepositoryModule = createModule(); +const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_TO_USER_REPOSITORY; +const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.ATTRIBUTE_TO_USER_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: attributeToUserRepositoryModule, + moduleToken, + token, + classs: PrismaAttributeToUserRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { PrismaAttributeToUserRepository }; diff --git a/packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.module.ts b/packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.module.ts index 3c1fdd0ade00fc..3e6a9dd84684b7 100644 --- a/packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.module.ts +++ b/packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.module.ts @@ -1,6 +1,7 @@ import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; import { moduleLoader as credentialRepositoryModuleLoader } from "@calcom/features/di/modules/Credential"; import { IntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService"; +import { moduleLoader as teamRepositoryModuleLoader } from "@calcom/features/oauth/di/TeamRepository.module"; import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens"; import { moduleLoader as integrationAttributeSyncRepositoryModuleLoader } from "./IntegrationAttributeSyncRepository.module"; @@ -16,6 +17,7 @@ const loadModule = bindModuleToClassOnToken({ depsMap: { credentialRepository: credentialRepositoryModuleLoader, integrationAttributeSyncRepository: integrationAttributeSyncRepositoryModuleLoader, + teamRepository: teamRepositoryModuleLoader, }, }); diff --git a/packages/features/ee/integration-attribute-sync/di/tokens.ts b/packages/features/ee/integration-attribute-sync/di/tokens.ts index aa1378c8267c43..08890d38bb8183 100644 --- a/packages/features/ee/integration-attribute-sync/di/tokens.ts +++ b/packages/features/ee/integration-attribute-sync/di/tokens.ts @@ -3,4 +3,14 @@ export const INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS = { INTEGRATION_ATTRIBUTE_SYNC_SERVICE_MODULE: Symbol("IntegrationAttributeSyncServiceModule"), INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY: Symbol("IntegrationAttributeSyncRepository"), INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY_MODULE: Symbol("IntegrationAttributeSyncRepositoryModule"), + ATTRIBUTE_SYNC_RULE_SERVICE: Symbol("AttributeSyncRuleService"), + ATTRIBUTE_SYNC_RULE_SERVICE_MODULE: Symbol("AttributeSyncRuleServiceModule"), + ATTRIBUTE_TO_USER_REPOSITORY: Symbol("AttributeToUserRepository"), + ATTRIBUTE_TO_USER_REPOSITORY_MODULE: Symbol("AttributeToUserRepositoryModule"), + ATTRIBUTE_SYNC_FIELD_MAPPING_SERVICE: Symbol("AttributeSyncFieldMappingService"), + ATTRIBUTE_SYNC_FIELD_MAPPING_SERVICE_MODULE: Symbol("AttributeSyncFieldMappingServiceModule"), + ATTRIBUTE_REPOSITORY: Symbol("AttributeRepository"), + ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"), + ATTRIBUTE_OPTION_REPOSITORY: Symbol("AttributeOptionRepository"), + ATTRIBUTE_OPTION_REPOSITORY_MODULE: Symbol("AttributeOptionRepositoryModule"), }; diff --git a/packages/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository.ts b/packages/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository.ts index df6c90fcd764af..31be6a54f4a726 100644 --- a/packages/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository.ts +++ b/packages/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository.ts @@ -123,14 +123,31 @@ export interface IUpdateAttributeSyncInput { } export interface IIntegrationAttributeSyncRepository { - getByOrganizationId(organizationId: number): Promise; + getByOrganizationId( + organizationId: number + ): Promise; getById(id: string): Promise; - getSyncFieldMappings(integrationAttributeSyncId: string): Promise; - getMappedAttributeIdsByOrganization(organizationId: number, excludeSyncId?: string): Promise; - getAttributeIdsByOrganization(organizationId: number, attributeIds: string[]): Promise; - create(params: IIntegrationAttributeSyncCreateParams): Promise; - updateTransactionWithRuleAndMappings(params: IIntegrationAttributeSyncUpdateParams): Promise; + getSyncFieldMappings( + integrationAttributeSyncId: string + ): Promise; + getMappedAttributeIdsByOrganization( + organizationId: number, + excludeSyncId?: string + ): Promise; + getAttributeIdsByOrganization( + organizationId: number, + attributeIds: string[] + ): Promise; + create( + params: IIntegrationAttributeSyncCreateParams + ): Promise; + updateTransactionWithRuleAndMappings( + params: IIntegrationAttributeSyncUpdateParams + ): Promise; deleteById(id: string): Promise; + getAllByCredentialId( + credentialId: number + ): Promise; } export interface IIntegrationAttributeSyncUpdateParams { diff --git a/packages/features/ee/integration-attribute-sync/repositories/PrismaIntegrationAttributeSyncRepository.ts b/packages/features/ee/integration-attribute-sync/repositories/PrismaIntegrationAttributeSyncRepository.ts index 154fbd36a151b4..3d3dfc88c0b153 100644 --- a/packages/features/ee/integration-attribute-sync/repositories/PrismaIntegrationAttributeSyncRepository.ts +++ b/packages/features/ee/integration-attribute-sync/repositories/PrismaIntegrationAttributeSyncRepository.ts @@ -1,5 +1,5 @@ -import type { Prisma } from "@calcom/prisma/client"; import type { PrismaClient } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; import { IntegrationAttributeSyncOutputMapper } from "../mappers/IntegrationAttributeSyncOutputMapper"; import type { @@ -207,4 +207,24 @@ export class PrismaIntegrationAttributeSyncRepository implements IIntegrationAtt }) .then(() => {}); } + + async getAllByCredentialId(credentialId: number) { + const integrationAttributeSyncsQuery = await this.prismaClient.integrationAttributeSync.findMany({ + where: { + credentialId, + }, + select: { + id: true, + name: true, + organizationId: true, + integration: true, + credentialId: true, + enabled: true, + attributeSyncRule: true, + syncFieldMappings: true, + }, + }); + + return IntegrationAttributeSyncOutputMapper.toDomainList(integrationAttributeSyncsQuery); + } } diff --git a/packages/features/ee/integration-attribute-sync/services/AttributeSyncFieldMappingService.test.ts b/packages/features/ee/integration-attribute-sync/services/AttributeSyncFieldMappingService.test.ts new file mode 100644 index 00000000000000..ba62a6fd18bf34 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/services/AttributeSyncFieldMappingService.test.ts @@ -0,0 +1,560 @@ +import type { PrismaAttributeOptionRepository } from "@calcom/features/attributes/repositories/PrismaAttributeOptionRepository"; +import type { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository"; +import type { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; +import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IFieldMapping } from "../repositories/IIntegrationAttributeSyncRepository"; +import { AttributeSyncFieldMappingService } from "./AttributeSyncFieldMappingService"; + +describe("AttributeSyncFieldMappingService", () => { + let service: AttributeSyncFieldMappingService; + let mockAttributeToUserRepository: { + deleteMany: ReturnType; + createManySkipDuplicates: ReturnType; + }; + let mockAttributeRepository: { + findManyByIdsAndOrgIdWithOptions: ReturnType; + }; + let mockAttributeOptionRepository: { + createMany: ReturnType; + findMany: ReturnType; + }; + let mockMembershipRepository: { + findUniqueByUserIdAndTeamId: ReturnType; + }; + + beforeEach(() => { + vi.resetAllMocks(); + + mockAttributeToUserRepository = { + deleteMany: vi.fn(), + createManySkipDuplicates: vi.fn(), + }; + + mockAttributeRepository = { + findManyByIdsAndOrgIdWithOptions: vi.fn(), + }; + + mockAttributeOptionRepository = { + createMany: vi.fn(), + findMany: vi.fn(), + }; + + mockMembershipRepository = { + findUniqueByUserIdAndTeamId: vi.fn(), + }; + + service = new AttributeSyncFieldMappingService({ + attributeToUserRepository: mockAttributeToUserRepository as unknown as PrismaAttributeToUserRepository, + attributeRepository: mockAttributeRepository as unknown as PrismaAttributeRepository, + attributeOptionRepository: mockAttributeOptionRepository as unknown as PrismaAttributeOptionRepository, + membershipRepository: mockMembershipRepository as unknown as MembershipRepository, + }); + }); + + describe("syncIntegrationFieldsToAttributes", () => { + const baseParams = { + userId: 1, + organizationId: 100, + }; + + it("should return early when no membership found", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue(null); + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings: [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + ], + integrationFields: { Department: "Engineering" }, + }); + + expect(mockAttributeRepository.findManyByIdsAndOrgIdWithOptions).not.toHaveBeenCalled(); + expect(mockAttributeToUserRepository.deleteMany).not.toHaveBeenCalled(); + expect(mockAttributeToUserRepository.createManySkipDuplicates).not.toHaveBeenCalled(); + }); + + it("should return early when no enabled mappings with matching integration fields", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings: [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: false }, + ], + integrationFields: { Department: "Engineering" }, + }); + + expect(mockAttributeRepository.findManyByIdsAndOrgIdWithOptions).not.toHaveBeenCalled(); + }); + + it("should sync SINGLE_SELECT attribute with matching option", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Department", + type: "SINGLE_SELECT", + options: [ + { id: "opt-1", value: "Engineering", slug: "engineering" }, + { id: "opt-2", value: "Sales", slug: "sales" }, + ], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "Engineering" }, + }); + + expect(mockAttributeToUserRepository.deleteMany).toHaveBeenCalledWith({ + memberId: 1, + attributeOption: { + attributeId: { in: ["attr-1"] }, + }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "opt-1" }, + ]); + }); + + it("should sync MULTI_SELECT attribute with multiple matching options", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Skills", + type: "MULTI_SELECT", + options: [ + { id: "opt-1", value: "JavaScript", slug: "javascript" }, + { id: "opt-2", value: "TypeScript", slug: "typescript" }, + { id: "opt-3", value: "Python", slug: "python" }, + ], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Skills", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Skills: "JavaScript, TypeScript" }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "opt-1" }, + { memberId: 1, attributeOptionId: "opt-2" }, + ]); + }); + + it("should handle case-insensitive option matching", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Department", + type: "SINGLE_SELECT", + options: [{ id: "opt-1", value: "Engineering", slug: "engineering" }], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "ENGINEERING" }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "opt-1" }, + ]); + }); + + it("should create new option for TEXT attribute when no matching option exists", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Title", + type: "TEXT", + options: [], + }, + ]); + + mockAttributeOptionRepository.findMany.mockResolvedValue([ + { id: "new-opt-1", attributeId: "attr-1", value: "Senior Engineer", slug: "senior-engineer" }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Title", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Title: "Senior Engineer" }, + }); + + expect(mockAttributeOptionRepository.createMany).toHaveBeenCalledWith({ + createManyInput: [{ attributeId: "attr-1", value: "Senior Engineer", slug: "senior-engineer" }], + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "new-opt-1" }, + ]); + }); + + it("should use existing option for TEXT attribute when matching option exists", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Title", + type: "TEXT", + options: [{ id: "opt-1", value: "Senior Engineer", slug: "senior-engineer" }], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Title", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Title: "Senior Engineer" }, + }); + + expect(mockAttributeOptionRepository.createMany).not.toHaveBeenCalled(); + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "opt-1" }, + ]); + }); + + it("should skip mapping when attribute not found", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "Engineering" }, + }); + + expect(mockAttributeToUserRepository.deleteMany).not.toHaveBeenCalled(); + expect(mockAttributeToUserRepository.createManySkipDuplicates).not.toHaveBeenCalled(); + }); + + it("should skip non-matching options for SINGLE_SELECT attribute", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Department", + type: "SINGLE_SELECT", + options: [ + { id: "opt-1", value: "Engineering", slug: "engineering" }, + { id: "opt-2", value: "Sales", slug: "sales" }, + ], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "Marketing" }, + }); + + expect(mockAttributeToUserRepository.deleteMany).toHaveBeenCalledWith({ + memberId: 1, + attributeOption: { + attributeId: { in: ["attr-1"] }, + }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).not.toHaveBeenCalled(); + }); + + it("should handle multiple mappings for different attributes", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Department", + type: "SINGLE_SELECT", + options: [{ id: "opt-1", value: "Engineering", slug: "engineering" }], + }, + { + id: "attr-2", + name: "Level", + type: "SINGLE_SELECT", + options: [{ id: "opt-2", value: "Senior", slug: "senior" }], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + { id: "mapping-2", integrationFieldName: "Level", attributeId: "attr-2", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "Engineering", Level: "Senior" }, + }); + + expect(mockAttributeToUserRepository.deleteMany).toHaveBeenCalledWith({ + memberId: 1, + attributeOption: { + attributeId: { in: ["attr-1", "attr-2"] }, + }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "opt-1" }, + { memberId: 1, attributeOptionId: "opt-2" }, + ]); + }); + + it("should only process enabled mappings", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Department", + type: "SINGLE_SELECT", + options: [{ id: "opt-1", value: "Engineering", slug: "engineering" }], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + { id: "mapping-2", integrationFieldName: "Level", attributeId: "attr-2", enabled: false }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "Engineering", Level: "Senior" }, + }); + + expect(mockAttributeRepository.findManyByIdsAndOrgIdWithOptions).toHaveBeenCalledWith({ + attributeIds: ["attr-1"], + orgId: 100, + }); + }); + + it("should handle SINGLE_SELECT with comma-separated value by taking first value only", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Department", + type: "SINGLE_SELECT", + options: [ + { id: "opt-1", value: "Engineering", slug: "engineering" }, + { id: "opt-2", value: "Sales", slug: "sales" }, + ], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Department", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Department: "Engineering, Sales" }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).toHaveBeenCalledWith([ + { memberId: 1, attributeOptionId: "opt-1" }, + ]); + }); + + it("should handle NUMBER attribute type", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Years of Experience", + type: "NUMBER", + options: [], + }, + ]); + + mockAttributeOptionRepository.findMany.mockResolvedValue([ + { id: "new-opt-1", attributeId: "attr-1", value: "5", slug: "5" }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Experience", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Experience: "5" }, + }); + + expect(mockAttributeOptionRepository.createMany).toHaveBeenCalledWith({ + createManyInput: [{ attributeId: "attr-1", value: "5", slug: "5" }], + }); + }); + + it("should clear old assignments even when field value is empty", async () => { + mockMembershipRepository.findUniqueByUserIdAndTeamId.mockResolvedValue({ + id: 1, + userId: 1, + teamId: 100, + accepted: true, + role: "MEMBER", + disableImpersonation: false, + }); + + mockAttributeRepository.findManyByIdsAndOrgIdWithOptions.mockResolvedValue([ + { + id: "attr-1", + name: "Skills", + type: "MULTI_SELECT", + options: [{ id: "opt-1", value: "JavaScript", slug: "javascript" }], + }, + ]); + + const syncFieldMappings: IFieldMapping[] = [ + { id: "mapping-1", integrationFieldName: "Skills", attributeId: "attr-1", enabled: true }, + ]; + + await service.syncIntegrationFieldsToAttributes({ + ...baseParams, + syncFieldMappings, + integrationFields: { Skills: "" }, + }); + + expect(mockAttributeToUserRepository.deleteMany).toHaveBeenCalledWith({ + memberId: 1, + attributeOption: { + attributeId: { in: ["attr-1"] }, + }, + }); + + expect(mockAttributeToUserRepository.createManySkipDuplicates).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/features/ee/integration-attribute-sync/services/AttributeSyncFieldMappingService.ts b/packages/features/ee/integration-attribute-sync/services/AttributeSyncFieldMappingService.ts new file mode 100644 index 00000000000000..1eb42a1bf84fb9 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/services/AttributeSyncFieldMappingService.ts @@ -0,0 +1,271 @@ +import { PrismaAttributeOptionRepository } from "@calcom/features/attributes/repositories/PrismaAttributeOptionRepository"; +import { + buildSlugFromValue, + canSetValueBeyondOptions, + doesSupportMultipleValues, + hasOptions, +} from "@calcom/features/ee/dsync/lib/assignValueToUserUtils"; +import logger from "@calcom/lib/logger"; +import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository"; +import { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; +import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { IFieldMapping } from "../repositories/IIntegrationAttributeSyncRepository"; +import type { AttributeType } from "@calcom/prisma/enums"; + +const log = logger.getSubLogger({ + prefix: ["[AttributeSyncFieldMappingService]"], +}); + +interface IAttributeSyncFieldMappingServiceDeps { + attributeToUserRepository: PrismaAttributeToUserRepository; + attributeRepository: PrismaAttributeRepository; + attributeOptionRepository: PrismaAttributeOptionRepository; + membershipRepository: MembershipRepository; +} + +export class AttributeSyncFieldMappingService { + constructor(private readonly deps: IAttributeSyncFieldMappingServiceDeps) {} + + async syncIntegrationFieldsToAttributes({ + userId, + organizationId, + syncFieldMappings, + integrationFields, + }: { + userId: number; + organizationId: number; + syncFieldMappings: IFieldMapping[]; + integrationFields: Record; + }): Promise { + const membership = await this.deps.membershipRepository.findUniqueByUserIdAndTeamId({ + userId, + teamId: organizationId, + }); + + if (!membership) { + log.warn( + `No membership found for user ${userId} in org ${organizationId}` + ); + return; + } + + const memberId = membership.id; + + const enabledSyncFieldMappings = syncFieldMappings.filter( + (mapping) => + mapping.enabled && + integrationFields[mapping.integrationFieldName] !== undefined + ); + + if (enabledSyncFieldMappings.length === 0) { + log.warn("No enabled mappings with matching integration fields"); + return; + } + + const attributeIds = enabledSyncFieldMappings.map((m) => m.attributeId); + + const attributes = + await this.deps.attributeRepository.findManyByIdsAndOrgIdWithOptions({ + attributeIds, + orgId: organizationId, + }); + + const attributeMap = new Map(attributes.map((a) => [a.id, a])); + + const { attributeIdsToSync, optionsToCreate, assignmentsToCreate } = + this.processMappings({ + enabledMappings: enabledSyncFieldMappings, + attributeMap, + integrationFields, + memberId, + orgId: organizationId, + }); + + if (optionsToCreate.length > 0) { + const newAssignments = await this.createOptionsAndGetAssignments({ + optionsToCreate, + memberId, + orgId: organizationId, + }); + assignmentsToCreate.push(...newAssignments); + } + + // Delete old assignments before creating new ones + if (attributeIdsToSync.length > 0) { + await this.deps.attributeToUserRepository.deleteMany({ + memberId, + attributeOption: { + attributeId: { in: attributeIdsToSync }, + }, + }); + } + + if (assignmentsToCreate.length > 0) { + await this.deps.attributeToUserRepository.createManySkipDuplicates(assignmentsToCreate); + + log.info( + `Synced ${assignmentsToCreate.length} attribute(s) for member ${memberId}` + ); + } + } + + private processMappings({ + enabledMappings, + attributeMap, + integrationFields, + memberId, + orgId, + }: { + enabledMappings: IFieldMapping[]; + attributeMap: Map< + string, + { + id: string; + name: string; + type: AttributeType; + options: { id: string; value: string; slug: string }[]; + } + >; + integrationFields: Record; + memberId: number; + orgId: number; + }): { + attributeIdsToSync: string[]; + optionsToCreate: Array<{ + attributeId: string; + value: string; + slug: string; + }>; + assignmentsToCreate: Array<{ memberId: number; attributeOptionId: string }>; + } { + const attributeIdsToSync: string[] = []; + const optionsToCreate: Array<{ + attributeId: string; + value: string; + slug: string; + }> = []; + const assignmentsToCreate: Array<{ + memberId: number; + attributeOptionId: string; + }> = []; + + for (const mapping of enabledMappings) { + const attribute = attributeMap.get(mapping.attributeId); + if (!attribute) { + log.warn(`Attribute ${mapping.attributeId} not found for org ${orgId}`); + continue; + } + + const rawFieldValue = String( + integrationFields[mapping.integrationFieldName] + ); + + if (hasOptions({ attribute })) { + // SINGLE_SELECT / MULTI_SELECT - must find existing option + + const isMultiSelect = doesSupportMultipleValues({ attribute }); + + // For MULTI_SELECT, process all comma-separated values; for SINGLE_SELECT, take the first only + const fieldValues = isMultiSelect + ? rawFieldValue + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + : [rawFieldValue.split(",")[0].trim()].filter(Boolean); + + // Always mark for sync so old assignments get cleared (even if field is blank) + attributeIdsToSync.push(attribute.id); + + for (const fieldValue of fieldValues) { + const matchingOption = attribute.options.find( + (opt) => opt.value.toLowerCase() === fieldValue.toLowerCase() + ); + + if (!matchingOption) { + log.warn( + `No matching option for value "${fieldValue}" in attribute ${attribute.id} (${attribute.name})` + ); + continue; + } + + assignmentsToCreate.push({ + memberId, + attributeOptionId: matchingOption.id, + }); + } + } else if (canSetValueBeyondOptions({ attribute })) { + // TEXT / NUMBER - find existing option or create new one + // For TEXT/NUMBER, use first value if comma-separated + const fieldValue = rawFieldValue.split(",")[0].trim(); + const existingOption = attribute.options.find( + (opt) => opt.value.toLowerCase() === fieldValue.toLowerCase() + ); + + attributeIdsToSync.push(attribute.id); + + if (existingOption) { + assignmentsToCreate.push({ + memberId, + attributeOptionId: existingOption.id, + }); + } else { + // Need to create a new option + optionsToCreate.push({ + attributeId: attribute.id, + value: fieldValue, + slug: buildSlugFromValue({ value: fieldValue }), + }); + } + } + } + + return { attributeIdsToSync, optionsToCreate, assignmentsToCreate }; + } + + private async createOptionsAndGetAssignments({ + optionsToCreate, + memberId, + orgId, + }: { + optionsToCreate: Array<{ + attributeId: string; + value: string; + slug: string; + }>; + memberId: number; + orgId: number; + }): Promise> { + await this.deps.attributeOptionRepository.createMany({ + createManyInput: optionsToCreate, + }); + + const allOptions = await this.deps.attributeOptionRepository.findMany({ + orgId, + }); + + const optionLookup = new Map( + allOptions.map((o) => [`${o.attributeId}:${o.value.toLowerCase()}`, o]) + ); + + const assignments: Array<{ memberId: number; attributeOptionId: string }> = + []; + + for (const newOption of optionsToCreate) { + const key = `${newOption.attributeId}:${newOption.value.toLowerCase()}`; + const createdOption = optionLookup.get(key); + + if (createdOption) { + assignments.push({ + memberId, + attributeOptionId: createdOption.id, + }); + } else { + log.error( + `Failed to find newly created option for attribute ${newOption.attributeId}` + ); + } + } + + return assignments; + } +} diff --git a/packages/features/ee/integration-attribute-sync/services/AttributeSyncRuleService.test.ts b/packages/features/ee/integration-attribute-sync/services/AttributeSyncRuleService.test.ts new file mode 100644 index 00000000000000..7a910bcec9439b --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/services/AttributeSyncRuleService.test.ts @@ -0,0 +1,708 @@ +import type { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; + +import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + ConditionIdentifierEnum, + ConditionOperatorEnum, + type IAttributeSyncRule, + RuleOperatorEnum, +} from "../repositories/IIntegrationAttributeSyncRepository"; +import { AttributeSyncRuleService } from "./AttributeSyncRuleService"; + +vi.mock("@calcom/features/attributes/di/AttributeService.container", () => ({ + getAttributeService: vi.fn(() => ({ + getUsersAttributesByOrgMembershipId: vi.fn(), + })), +})); + +import { getAttributeService } from "@calcom/features/attributes/di/AttributeService.container"; + +describe("AttributeSyncRuleService", () => { + let service: AttributeSyncRuleService; + let mockMembershipRepository: { + findAllByUserId: ReturnType; + }; + let mockAttributeToUserRepository: { + findManyIncludeAttribute: ReturnType; + }; + let mockAttributeService: { + getUsersAttributesByOrgMembershipId: ReturnType; + }; + + beforeEach(() => { + vi.resetAllMocks(); + + mockMembershipRepository = { + findAllByUserId: vi.fn(), + }; + + mockAttributeToUserRepository = { + findManyIncludeAttribute: vi.fn(), + }; + + mockAttributeService = { + getUsersAttributesByOrgMembershipId: vi.fn(), + }; + + vi.mocked(getAttributeService).mockReturnValue(mockAttributeService); + + service = new AttributeSyncRuleService({ + membershipRepository: mockMembershipRepository as unknown as MembershipRepository, + attributeToUserRepository: mockAttributeToUserRepository as unknown as PrismaAttributeToUserRepository, + }); + }); + + describe("shouldSyncApplyToUser", () => { + describe("team conditions", () => { + it("should return true when user is in specified team with IN operator and AND rule", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([ + { teamId: 10, accepted: true }, + { teamId: 30, accepted: true }, + ]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + expect(mockMembershipRepository.findAllByUserId).toHaveBeenCalledWith({ + userId: 1, + filters: { accepted: true }, + }); + }); + + it("should return false when user is not in any specified team with IN operator and AND rule", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10, 20], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([ + { teamId: 30, accepted: true }, + { teamId: 40, accepted: true }, + ]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return true when user is not in specified team with NOT_IN operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.NOT_IN, + value: [10], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 20, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false when user is in specified team with NOT_IN operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.NOT_IN, + value: [10], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 10, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should evaluate multiple team IDs in a single condition - all must match for AND rule", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10, 20], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([ + { teamId: 10, accepted: true }, + { teamId: 20, accepted: true }, + ]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false when user is not in all specified teams with IN operator and AND rule", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10, 20, 30], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([ + { teamId: 10, accepted: true }, + { teamId: 20, accepted: true }, + ]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return true when user is in any specified team with IN operator and OR rule", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.OR, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10, 20, 30], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 10, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + }); + + describe("attribute conditions", () => { + it("should return true when user has matching SINGLE_SELECT attribute with EQUALS operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-1", + value: "Option 1", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false when user has non-matching SINGLE_SELECT attribute with EQUALS operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-2", + value: "Option 2", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return true when user has matching TEXT attribute with EQUALS operator (case-insensitive)", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["ENGINEERING"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "TEXT", + optionId: null, + value: "engineering", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return true when user has MULTI_SELECT attribute containing all required options with IN operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.IN, + value: ["option-1", "option-2"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "MULTI_SELECT", + optionIds: new Set(["option-1", "option-2", "option-3"]), + values: new Set(["Option 1", "Option 2", "Option 3"]), + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false when user has MULTI_SELECT attribute missing some required options with IN operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.IN, + value: ["option-1", "option-2"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "MULTI_SELECT", + optionIds: new Set(["option-1", "option-3"]), + values: new Set(["Option 1", "Option 3"]), + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return true when user has MULTI_SELECT attribute without any excluded options with NOT_IN operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.NOT_IN, + value: ["option-4", "option-5"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "MULTI_SELECT", + optionIds: new Set(["option-1", "option-2"]), + values: new Set(["Option 1", "Option 2"]), + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false when user has MULTI_SELECT attribute with some excluded options with NOT_IN operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.NOT_IN, + value: ["option-1", "option-5"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "MULTI_SELECT", + optionIds: new Set(["option-1", "option-2"]), + values: new Set(["Option 1", "Option 2"]), + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return false when user does not have the attribute with IN/EQUALS operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.IN, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return true when user does not have the attribute with NOT_IN/NOT_EQUALS operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.NOT_IN, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return true when user has non-matching SINGLE_SELECT attribute with NOT_EQUALS operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.NOT_EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-2", + value: "Option 2", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + }); + + describe("rule operators", () => { + it("should return true with AND operator when all conditions pass", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10], + }, + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 10, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-1", + value: "Option 1", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false with AND operator when any condition fails", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10], + }, + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 10, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-2", + value: "Option 2", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should return true with OR operator when any condition passes", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.OR, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10], + }, + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 20, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-1", + value: "Option 1", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false with OR operator when all conditions fail", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.OR, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10], + }, + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["option-1"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([{ teamId: 20, accepted: true }]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "SINGLE_SELECT", + optionId: "option-2", + value: "Option 2", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should return true with AND operator and empty conditions", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + + it("should return false with OR operator and empty conditions", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.OR, + conditions: [], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should handle user with no team memberships", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.TEAM_ID, + operator: ConditionOperatorEnum.IN, + value: [10], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({}); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(false); + }); + + it("should handle NUMBER attribute type with EQUALS operator", async () => { + const user = { id: 1, organizationId: 100 }; + const attributeSyncRule: IAttributeSyncRule = { + operator: RuleOperatorEnum.AND, + conditions: [ + { + identifier: ConditionIdentifierEnum.ATTRIBUTE_ID, + attributeId: "attr-1", + operator: ConditionOperatorEnum.EQUALS, + value: ["42"], + }, + ], + }; + + mockMembershipRepository.findAllByUserId.mockResolvedValue([]); + mockAttributeService.getUsersAttributesByOrgMembershipId.mockResolvedValue({ + "attr-1": { + type: "NUMBER", + optionId: null, + value: "42", + }, + }); + + const result = await service.shouldSyncApplyToUser({ user, attributeSyncRule }); + + expect(result).toBe(true); + }); + }); + }); +}); diff --git a/packages/features/ee/integration-attribute-sync/services/AttributeSyncRuleService.ts b/packages/features/ee/integration-attribute-sync/services/AttributeSyncRuleService.ts new file mode 100644 index 00000000000000..86c63c4c6e28f7 --- /dev/null +++ b/packages/features/ee/integration-attribute-sync/services/AttributeSyncRuleService.ts @@ -0,0 +1,197 @@ +import { isTeamCondition, isAttributeCondition } from "../lib/ruleHelpers"; +import { + ConditionOperatorEnum, + type IAttributeSyncRule, + type ITeamCondition, + type IAttributeCondition, + RuleOperatorEnum, +} from "../repositories/IIntegrationAttributeSyncRepository"; +import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import type { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository"; +import { getAttributeService } from "@calcom/features/attributes/di/AttributeService.container"; +import type { UserAttribute } from "@calcom/features/attributes/services/AttributeService"; + +interface IAttributeSyncRuleServiceDeps { + membershipRepository: MembershipRepository; + attributeToUserRepository: PrismaAttributeToUserRepository; +} + +export class AttributeSyncRuleService { + constructor(private readonly deps: IAttributeSyncRuleServiceDeps) {} + + async shouldSyncApplyToUser({ + user, + attributeSyncRule, + }: { + user: { id: number; organizationId: number }; + attributeSyncRule: IAttributeSyncRule; + }): Promise { + const { operator: ruleOperator, conditions } = attributeSyncRule; + + const teamConditions = conditions.filter(isTeamCondition); + const attributeConditions = conditions.filter(isAttributeCondition); + + const conditionChecks = await Promise.all([ + this.handleTeamConditions({ userId: user.id, teamConditions }), + this.handleAttributeConditions({ user, attributeConditions }), + ]); + + const conditionChecksFlattened = conditionChecks.flat(); + + if (ruleOperator === RuleOperatorEnum.AND) { + return !conditionChecksFlattened.some((condition) => !condition); + } else { + return conditionChecksFlattened.some((condition) => condition); + } + } + + private async handleTeamConditions({ + userId, + teamConditions, + }: { + userId: number; + teamConditions: ITeamCondition[]; + }) { + const userMemberships = + await this.deps.membershipRepository.findAllByUserId({ + userId, + filters: { accepted: true }, + }); + + const userTeamIdSet = new Set( + userMemberships.map((membership) => membership.teamId) + ); + + const teamConditionEvaluated: boolean[] = []; + + for (const teamCondition of teamConditions) { + const teamIds = teamCondition.value; + const conditionOperator = teamCondition.operator; + + for (const teamId of teamIds) { + if (conditionOperator === ConditionOperatorEnum.IN) { + teamConditionEvaluated.push(userTeamIdSet.has(teamId)); + } + if (conditionOperator === ConditionOperatorEnum.NOT_IN) { + teamConditionEvaluated.push(!userTeamIdSet.has(teamId)); + } + } + } + + return teamConditionEvaluated; + } + + private async handleAttributeConditions({ + user, + attributeConditions, + }: { + user: { id: number; organizationId: number }; + attributeConditions: IAttributeCondition[]; + }): Promise { + const attributeService = getAttributeService(); + + const userAttributes = + await attributeService.getUsersAttributesByOrgMembershipId({ + userId: user.id, + orgId: user.organizationId, + }); + + const attributeConditionResults: boolean[] = []; + + for (const condition of attributeConditions) { + const userAttribute = userAttributes[condition.attributeId]; + const result = this.evaluateAttributeCondition(userAttribute, condition); + attributeConditionResults.push(result); + } + + return attributeConditionResults; + } + + private evaluateAttributeCondition( + userAttribute: UserAttribute | undefined, + condition: IAttributeCondition + ): boolean { + const { operator } = condition; + + if (!userAttribute) { + if ( + operator === ConditionOperatorEnum.IN || + operator === ConditionOperatorEnum.EQUALS + ) { + return false; + } + // For NOT_IN/NOT_EQUALS: user doesn't have attribute, condition passes + return true; + } + + if (userAttribute.type === "MULTI_SELECT") { + return this.evaluateMultiSelectCondition( + userAttribute, + operator, + condition + ); + } else { + return this.evaluateSingleValueCondition( + userAttribute, + operator, + condition + ); + } + } + + private evaluateMultiSelectCondition( + userAttribute: Extract, + operator: ConditionOperatorEnum, + condition: IAttributeCondition + ): boolean { + const userAttributeOptionIds = userAttribute.optionIds; + const conditionAttributeOptionIds = condition.value; + + switch (operator) { + case ConditionOperatorEnum.IN: + return conditionAttributeOptionIds.every((id) => + userAttributeOptionIds.has(id) + ); + + case ConditionOperatorEnum.NOT_IN: + return !conditionAttributeOptionIds.some((id) => + userAttributeOptionIds.has(id) + ); + + default: + return false; + } + } + + private evaluateSingleValueCondition( + userAttribute: Extract< + UserAttribute, + { type: "TEXT" | "NUMBER" | "SINGLE_SELECT" } + >, + operator: ConditionOperatorEnum, + condition: IAttributeCondition + ): boolean { + // For SINGLE_SELECT: condition stores option IDs, compare against optionId (exact match) + // For TEXT/NUMBER: condition stores actual values, compare against value (case-insensitive) + const isSingleSelect = userAttribute.type === "SINGLE_SELECT"; + + const userValue = isSingleSelect + ? userAttribute.optionId + : userAttribute.value?.toLowerCase() ?? null; + + const conditionValue = isSingleSelect + ? condition.value[0] ?? null + : condition.value[0]?.toLowerCase() ?? null; + + switch (operator) { + case ConditionOperatorEnum.EQUALS: + return userValue === conditionValue; + + case ConditionOperatorEnum.NOT_EQUALS: + return userValue !== conditionValue; + + default: + return false; + } + } +} diff --git a/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts b/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts index 37efeca38242b8..d3a5ced66fba55 100644 --- a/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts +++ b/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; +import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { enabledAppSlugs } from "../constants"; import type { IIntegrationAttributeSyncRepository } from "../repositories/IIntegrationAttributeSyncRepository"; -import { IntegrationAttributeSyncService, UnauthorizedAttributeError } from "./IntegrationAttributeSyncService"; +import { + IntegrationAttributeSyncService, + UnauthorizedAttributeError, +} from "./IntegrationAttributeSyncService"; describe("IntegrationAttributeSyncService", () => { let service: IntegrationAttributeSyncService; @@ -20,6 +24,9 @@ describe("IntegrationAttributeSyncService", () => { updateTransactionWithRuleAndMappings: ReturnType; deleteById: ReturnType; }; + let mockTeamRepository: { + findTeamsNotBelongingToOrgByIds: ReturnType; + }; beforeEach(() => { vi.resetAllMocks(); @@ -38,10 +45,15 @@ describe("IntegrationAttributeSyncService", () => { deleteById: vi.fn(), }; + mockTeamRepository = { + findTeamsNotBelongingToOrgByIds: vi.fn(), + }; + service = new IntegrationAttributeSyncService({ credentialRepository: mockCredentialRepository as unknown as CredentialRepository, integrationAttributeSyncRepository: mockIntegrationAttributeSyncRepository as unknown as IIntegrationAttributeSyncRepository, + teamRepository: mockTeamRepository as unknown as TeamRepository, }); }); @@ -171,6 +183,7 @@ describe("IntegrationAttributeSyncService", () => { mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]); mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]); mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); await service.updateIncludeRulesAndMappings(formData); @@ -208,6 +221,7 @@ describe("IntegrationAttributeSyncService", () => { { id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true }, ]); mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); await service.updateIncludeRulesAndMappings(formData); @@ -245,6 +259,7 @@ describe("IntegrationAttributeSyncService", () => { { id: "mapping-3", integrationFieldName: "field3", attributeId: "attr-3", enabled: true }, ]); mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); await service.updateIncludeRulesAndMappings(formData); @@ -282,6 +297,7 @@ describe("IntegrationAttributeSyncService", () => { { id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true }, ]); mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); await service.updateIncludeRulesAndMappings(formData); @@ -412,6 +428,7 @@ describe("IntegrationAttributeSyncService", () => { mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]); mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]); mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); await expect(service.updateIncludeRulesAndMappings(formData)).resolves.not.toThrow(); }); @@ -426,6 +443,7 @@ describe("IntegrationAttributeSyncService", () => { mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]); mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]); mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); await expect(service.updateIncludeRulesAndMappings(formData)).resolves.not.toThrow(); }); @@ -441,4 +459,103 @@ describe("IntegrationAttributeSyncService", () => { expect(mockIntegrationAttributeSyncRepository.deleteById).toHaveBeenCalledWith(syncId); }); }); + + describe("team validation", () => { + describe("updateIncludeRulesAndMappings", () => { + const baseFormData = { + id: "sync-123", + name: "Test Sync", + credentialId: 1, + enabled: true, + organizationId: 123, + ruleId: "rule-123", + syncFieldMappings: [], + }; + + it("should throw error when team IDs do not belong to organization", async () => { + const formData = { + ...baseFormData, + rule: { + operator: "AND" as const, + conditions: [ + { + identifier: "teamId" as const, + operator: "equals" as const, + value: [1, 2, 3], + }, + ], + }, + }; + + mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]); + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([{ id: 2 }, { id: 3 }]); + + await expect(service.updateIncludeRulesAndMappings(formData)).rejects.toThrow( + "Teams do not belong to this organization: 2, 3" + ); + + expect(mockTeamRepository.findTeamsNotBelongingToOrgByIds).toHaveBeenCalledWith({ + teamIds: [1, 2, 3], + orgId: 123, + }); + }); + + it("should not validate teams when no team conditions in rule", async () => { + const formData = { + ...baseFormData, + rule: { + operator: "OR" as const, + conditions: [ + { + identifier: "attributeId" as const, + attributeId: "attr-123", + operator: "in" as const, + value: ["option-1"], + }, + ], + }, + }; + + mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + + await service.updateIncludeRulesAndMappings(formData); + + expect(mockTeamRepository.findTeamsNotBelongingToOrgByIds).not.toHaveBeenCalled(); + }); + + it("should proceed when all team IDs belong to organization", async () => { + const formData = { + ...baseFormData, + rule: { + operator: "AND" as const, + conditions: [ + { + identifier: "teamId" as const, + operator: "equals" as const, + value: [1, 2], + }, + ], + }, + }; + + mockTeamRepository.findTeamsNotBelongingToOrgByIds.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]); + mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined); + + await service.updateIncludeRulesAndMappings(formData); + + expect(mockTeamRepository.findTeamsNotBelongingToOrgByIds).toHaveBeenCalledWith({ + teamIds: [1, 2], + orgId: 123, + }); + expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.ts b/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.ts index f8ce783bfea93e..9643d3fac9e0e9 100644 --- a/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.ts +++ b/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.ts @@ -1,11 +1,15 @@ import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository"; - +import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import type { ZCreateAttributeSyncSchema } from "@calcom/trpc/server/routers/viewer/attribute-sync/createAttributeSync.schema"; + import { enabledAppSlugs } from "../constants"; import { type IIntegrationAttributeSyncRepository, type ISyncFormData, + type ITeamCondition, + type IAttributeSyncRule, AttributeSyncIntegrations, + ConditionIdentifierEnum, } from "../repositories/IIntegrationAttributeSyncRepository"; import { attributeSyncRuleSchema } from "../schemas/zod"; @@ -40,11 +44,42 @@ export class CredentialNotFoundError extends Error { interface IIntegrationAttributeSyncServiceDeps { credentialRepository: CredentialRepository; integrationAttributeSyncRepository: IIntegrationAttributeSyncRepository; + teamRepository: TeamRepository; } export class IntegrationAttributeSyncService { constructor(private readonly deps: IIntegrationAttributeSyncServiceDeps) {} + private extractTeamIdsFromRule(rule: IAttributeSyncRule): number[] { + return rule.conditions + .filter( + (c): c is ITeamCondition => + c.identifier === ConditionIdentifierEnum.TEAM_ID + ) + .flatMap((c) => c.value); + } + + private async validateTeamsBelongToOrg( + teamIds: number[], + organizationId: number + ): Promise { + if (teamIds.length === 0) return; + + const invalidTeams = + await this.deps.teamRepository.findTeamsNotBelongingToOrgByIds({ + teamIds, + orgId: organizationId, + }); + + if (invalidTeams.length > 0) { + throw new Error( + `Teams do not belong to this organization: ${invalidTeams + .map((t) => t.id) + .join(", ")}` + ); + } + } + private validateWithinSyncUniqueness( mappings: { attributeId: string }[] ): void { @@ -125,6 +160,9 @@ export class IntegrationAttributeSyncService { const parsedRule = attributeSyncRuleSchema.parse(input.rule); + const teamIds = this.extractTeamIdsFromRule(parsedRule); + await this.validateTeamsBelongToOrg(teamIds, organizationId); + const integrationValue = credential.app?.slug || credential.type; if ( !Object.values(AttributeSyncIntegrations).includes( @@ -177,6 +215,9 @@ export class IntegrationAttributeSyncService { const parsedRule = attributeSyncRuleSchema.parse(rule); + const teamIds = this.extractTeamIdsFromRule(parsedRule); + await this.validateTeamsBelongToOrg(teamIds, data.organizationId); + const incomingMappingIds = new Set( syncFieldMappings.reduce((ids, mapping) => { if ("id" in mapping) ids.push(mapping.id); @@ -209,4 +250,10 @@ export class IntegrationAttributeSyncService { async deleteById(id: string) { return this.deps.integrationAttributeSyncRepository.deleteById(id); } + + async getAllByCredentialId(credentialId: number) { + return this.deps.integrationAttributeSyncRepository.getAllByCredentialId( + credentialId + ); + } } diff --git a/packages/features/membership/repositories/MembershipRepository.ts b/packages/features/membership/repositories/MembershipRepository.ts index a84f8ea675b187..d9be3885742cb7 100644 --- a/packages/features/membership/repositories/MembershipRepository.ts +++ b/packages/features/membership/repositories/MembershipRepository.ts @@ -508,7 +508,7 @@ export class MembershipRepository { return teams; } - static async findAllByUserId({ + async findAllByUserId({ userId, filters, }: { @@ -518,7 +518,7 @@ export class MembershipRepository { roles?: MembershipRole[]; }; }) { - return prisma.membership.findMany({ + return this.prismaClient.membership.findMany({ where: { userId, ...(filters?.accepted !== undefined && { accepted: filters.accepted }), diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index e37b19e170832b..f5be9cf2294309 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -3,7 +3,10 @@ import type { z } from "zod"; import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import { getParsedTeam } from "@calcom/features/ee/teams/lib/getParsedTeam"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; -import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; +import { + DEFAULT_SCHEDULE, + getAvailabilityFromSchedule, +} from "@calcom/lib/availability"; import { buildNonDelegationCredentials } from "@calcom/lib/delegationCredential"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -11,7 +14,11 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { withSelectedCalendars } from "@calcom/lib/server/withSelectedCalendars"; import type { PrismaClient } from "@calcom/prisma"; import { availabilityUserSelect } from "@calcom/prisma"; -import type { User as UserType, DestinationCalendar, SelectedCalendar } from "@calcom/prisma/client"; +import type { + User as UserType, + DestinationCalendar, + SelectedCalendar, +} from "@calcom/prisma/client"; import { Prisma } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; import { MembershipRole, BookingStatus } from "@calcom/prisma/enums"; @@ -120,6 +127,7 @@ const userSelect = { lastActiveAt: true, identityProvider: true, teams: true, + profiles: true, } satisfies Prisma.UserSelect; export class UserRepository { @@ -137,8 +145,12 @@ export class UserRepository { }, }); - const acceptedTeamMemberships = teamMemberships.filter((membership) => membership.accepted); - const pendingTeamMemberships = teamMemberships.filter((membership) => !membership.accepted); + const acceptedTeamMemberships = teamMemberships.filter( + (membership) => membership.accepted + ); + const pendingTeamMemberships = teamMemberships.filter( + (membership) => !membership.accepted + ); return { teams: acceptedTeamMemberships.map((membership) => membership.team), @@ -157,7 +169,9 @@ export class UserRepository { (membership) => membership.team.isOrganization ); - const organizations = acceptedOrgMemberships.map((membership) => membership.team); + const organizations = acceptedOrgMemberships.map( + (membership) => membership.team + ); return { organizations, @@ -167,11 +181,18 @@ export class UserRepository { /** * It is aware of the fact that a user can be part of multiple organizations. */ - async findUsersByUsername({ orgSlug, usernameList }: { orgSlug: string | null; usernameList: string[] }) { - const { where, profiles } = await this._getWhereClauseForFindingUsersByUsername({ - orgSlug, - usernameList, - }); + async findUsersByUsername({ + orgSlug, + usernameList, + }: { + orgSlug: string | null; + usernameList: string[]; + }) { + const { where, profiles } = + await this._getWhereClauseForFindingUsersByUsername({ + orgSlug, + usernameList, + }); return ( await this.prismaClient.user.findMany({ @@ -186,9 +207,13 @@ export class UserRepository { profile: ProfileRepository.buildPersonalProfileFromUser({ user }), }; } - const profile = profiles.find((profile) => profile.user.id === user.id) ?? null; + const profile = + profiles.find((profile) => profile.user.id === user.id) ?? null; if (!profile) { - log.error("Profile not found for user", safeStringify({ user, profiles })); + log.error( + "Profile not found for user", + safeStringify({ user, profiles }) + ); // Profile must be there because profile itself was used to retrieve the user throw new Error("Profile couldn't be found"); } @@ -200,7 +225,11 @@ export class UserRepository { }); } - async findPlatformMembersByUsernames({ usernameList }: { usernameList: string[] }) { + async findPlatformMembersByUsernames({ + usernameList, + }: { + usernameList: string[]; + }) { return ( await this.prismaClient.user.findMany({ select: userSelect, @@ -259,7 +288,8 @@ export class UserRepository { }, ...(orgSlug ? { - organization: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), + organization: + whereClauseForOrgWithSlugOrRequestedSlug(orgSlug), } : { organization: null, @@ -278,7 +308,11 @@ export class UserRepository { return user; } - async findManyByEmailsWithEmailVerificationSettings({ emails }: { emails: string[] }) { + async findManyByEmailsWithEmailVerificationSettings({ + emails, + }: { + emails: string[]; + }) { const normalizedEmails = emails.map((e) => e.toLowerCase()); if (!normalizedEmails.length) return []; @@ -365,7 +399,8 @@ export class UserRepository { return null; } - const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(user); + const allProfiles = + await ProfileRepository.findAllProfilesForUserIncludingMovedUser(user); return { ...user, allProfiles, @@ -454,7 +489,9 @@ export class UserRepository { user: { profiles: { organizationId: number }[] }; organizationId: number; }) { - return user.profiles.some((profile) => profile.organizationId === organizationId); + return user.profiles.some( + (profile) => profile.organizationId === organizationId + ); } async findIfAMemberOfSomeOrganization({ user }: { user: { id: number } }) { @@ -477,7 +514,11 @@ export class UserRepository { return !!user.metadata?.migratedToOrgFrom; } - async isMovedToAProfile({ user }: { user: Pick }) { + async isMovedToAProfile({ + user, + }: { + user: Pick; + }) { return !!user.movedToProfileId; } @@ -519,13 +560,9 @@ export class UserRepository { return updatedUser; } - async enrichUserWithTheProfile({ - user, - upId, - }: { - user: T; - upId: UpId; - }) { + async enrichUserWithTheProfile< + T extends { username: string | null; id: number } + >({ user, upId }: { user: T; upId: UpId }) { const profile = await ProfileRepository.findByUpIdWithAuth(upId, user.id); if (!profile) { return { @@ -628,7 +665,9 @@ export class UserRepository { }; } - async enrichUsersWithTheirProfiles( + async enrichUsersWithTheirProfiles< + T extends { id: number; username: string | null } + >( users: T[] ): Promise< Array< @@ -655,7 +694,10 @@ export class UserRepository { // Precompute personal profiles for all users const personalProfileMap = new Map(); users.forEach((user) => { - personalProfileMap.set(user.id, ProfileRepository.buildPersonalProfileFromUser({ user })); + personalProfileMap.set( + user.id, + ProfileRepository.buildPersonalProfileFromUser({ user }) + ); }); return users.map((user) => { @@ -687,7 +729,9 @@ export class UserRepository { }); } - async enrichUsersWithTheirProfileExcludingOrgMetadata( + async enrichUsersWithTheirProfileExcludingOrgMetadata< + T extends { id: number; username: string | null } + >( users: T[] ): Promise< Array< @@ -723,7 +767,9 @@ export class UserRepository { }); } - enrichUserWithItsProfileBuiltFromUser({ + enrichUserWithItsProfileBuiltFromUser< + T extends { id: number; username: string | null } + >({ user, }: { user: T; @@ -787,7 +833,9 @@ export class UserRepository { if (!profiles.length) { return { ...entity, - profile: ProfileRepository.buildPersonalProfileFromUser({ user: entity.user }), + profile: ProfileRepository.buildPersonalProfileFromUser({ + user: entity.user, + }), }; } else { return { @@ -824,7 +872,10 @@ export class UserRepository { } async create( - data: Omit & { + data: Omit< + Prisma.UserCreateInput, + "password" | "organization" | "movedToProfile" + > & { username: string; hashedPassword?: string; organizationId: number | null; @@ -833,9 +884,15 @@ export class UserRepository { } ) { const organizationIdValue = data.organizationId; - const { email, username, creationSource, locked, hashedPassword, ...rest } = data; - - logger.info("create user", { email, username, organizationIdValue, locked }); + const { email, username, creationSource, locked, hashedPassword, ...rest } = + data; + + logger.info("create user", { + email, + username, + organizationIdValue, + locked, + }); const t = await getTranslation("en", "common"); const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); @@ -843,7 +900,9 @@ export class UserRepository { data: { username, email: email, - ...(hashedPassword && { password: { create: { hash: hashedPassword } } }), + ...(hashedPassword && { + password: { create: { hash: hashedPassword } }, + }), // Default schedule schedules: { create: { @@ -902,7 +961,9 @@ export class UserRepository { members: { some: { id: userId, - role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] }, + role: { + in: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, }, }, }, @@ -931,7 +992,13 @@ export class UserRepository { }, }); } - async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) { + async isAdminOfTeamOrParentOrg({ + userId, + teamId, + }: { + userId: number; + teamId: number; + }) { const membershipQuery = { members: { some: { @@ -956,7 +1023,13 @@ export class UserRepository { }); return !!teams.length; } - async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) { + async isAdminOrOwnerOfTeam({ + userId, + teamId, + }: { + userId: number; + teamId: number; + }) { const isAdminOrOwnerOfTeam = await this.prismaClient.membership.findUnique({ where: { userId_teamId: { @@ -1067,7 +1140,8 @@ export class UserRepository { return null; } - const { credentials, ...userWithSelectedCalendars } = withSelectedCalendars(user); + const { credentials, ...userWithSelectedCalendars } = + withSelectedCalendars(user); return { ...userWithSelectedCalendars, credentials: buildNonDelegationCredentials(credentials), @@ -1188,7 +1262,11 @@ export class UserRepository { }; } - async findManyByIdsIncludeDestinationAndSelectedCalendars({ ids }: { ids: number[] }) { + async findManyByIdsIncludeDestinationAndSelectedCalendars({ + ids, + }: { + ids: number[]; + }) { const users = await this.prismaClient.user.findMany({ where: { id: { in: ids } }, include: { @@ -1214,7 +1292,13 @@ export class UserRepository { }); } - async updateWhitelistWorkflows({ id, whitelistWorkflows }: { id: number; whitelistWorkflows: boolean }) { + async updateWhitelistWorkflows({ + id, + whitelistWorkflows, + }: { + id: number; + whitelistWorkflows: boolean; + }) { return this.prismaClient.user.update({ where: { id }, data: { whitelistWorkflows }, @@ -1260,7 +1344,13 @@ export class UserRepository { }); } - async findUsersWithLastBooking({ userIds, eventTypeId }: { userIds: number[]; eventTypeId: number }) { + async findUsersWithLastBooking({ + userIds, + eventTypeId, + }: { + userIds: number[]; + eventTypeId: number; + }) { return this.prismaClient.user.findMany({ where: { id: { @@ -1342,14 +1432,20 @@ export class UserRepository { * @param userId - The user ID * @returns User with username or null */ - async findByIdWithUsername(userId: number): Promise<{ username: string | null } | null> { + async findByIdWithUsername( + userId: number + ): Promise<{ username: string | null } | null> { return this.prismaClient.user.findUnique({ where: { id: userId }, select: { username: true }, }); } - async findManyByIdsWithCredentialsAndSelectedCalendars({ userIds }: { userIds: number[] }) { + async findManyByIdsWithCredentialsAndSelectedCalendars({ + userIds, + }: { + userIds: number[]; + }) { const users = await this.prismaClient.user.findMany({ where: { id: { @@ -1370,4 +1466,25 @@ export class UserRepository { }); return users.map(withSelectedCalendars); } + + async findByEmailAndTeamId({ + email, + teamId, + }: { + email: string; + teamId: number; + }) { + return this.prismaClient.user.findFirst({ + where: { + email: email.toLowerCase(), + teams: { + some: { + teamId, + accepted: true, + }, + }, + }, + select: userSelect, + }); + } } diff --git a/packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql b/packages/prisma/migrations/20251231032625_add_integration_attribute_sync/migration.sql similarity index 93% rename from packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql rename to packages/prisma/migrations/20251231032625_add_integration_attribute_sync/migration.sql index 8511bf8a36d1ea..e6b6af940532fd 100644 --- a/packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql +++ b/packages/prisma/migrations/20251231032625_add_integration_attribute_sync/migration.sql @@ -45,9 +45,6 @@ CREATE UNIQUE INDEX "AttributeSyncRule_integrationAttributeSyncId_key" ON "publi -- CreateIndex CREATE INDEX "AttributeSyncFieldMapping_integrationAttributeSyncId_idx" ON "public"."AttributeSyncFieldMapping"("integrationAttributeSyncId"); --- CreateIndex -CREATE UNIQUE INDEX "AttributeSyncFieldMapping_integrationAttributeSyncId_attrib_key" ON "public"."AttributeSyncFieldMapping"("integrationAttributeSyncId", "attributeId"); - -- AddForeignKey ALTER TABLE "public"."IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts index 131e2df4855542..b873816be8db19 100644 --- a/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts +++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/listCalls.handler.ts @@ -20,7 +20,8 @@ export const listCallsHandler = async ({ ctx, input }: ListCallsHandlerOptions) const organizationId = ctx.user.organizationId ?? ctx.user.profiles?.[0]?.organizationId; try { - const userMemberships = await MembershipRepository.findAllByUserId({ + const membershipRepository = new MembershipRepository(); + const userMemberships = await membershipRepository.findAllByUserId({ userId: ctx.user.id, filters: { accepted: true, diff --git a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts index 36154f40ab8911..53a34c6d11f50c 100644 --- a/packages/trpc/server/routers/viewer/featureOptIn/_router.ts +++ b/packages/trpc/server/routers/viewer/featureOptIn/_router.ts @@ -19,13 +19,14 @@ const featureStateSchema: ZodEnum<["enabled", "disabled", "inherit"]> = z.enum([ const featureOptInService = getFeatureOptInService(); const featuresRepository = new FeaturesRepository(prisma); const teamRepository = new TeamRepository(prisma); +const membershipRepository = new MembershipRepository(prisma); /** * Helper to get user's org and team IDs from their memberships. * Returns orgId (if user belongs to an org) and teamIds (non-org teams). */ async function getUserOrgAndTeamIds(userId: number): Promise<{ orgId: number | null; teamIds: number[] }> { - const memberships = await MembershipRepository.findAllByUserId({ + const memberships = await membershipRepository.findAllByUserId({ userId, filters: { accepted: true }, });