feat: Add attribute sync logic from Salesforce (#26517)
* Add db schema * Add `CredentialRepository.findByTeamIdAndSlugs` * Add enabled app slugs for attribute syncing * Create repository for `IntegrationAttributeSync` * Create zod schemas * Create `AttributeSyncUserRuleOutputMapper` * Create `IntegrationAttributeSyncService` * Create DI contianer * Create trpc endpoints * Create page * Include team name in `CredentialRepository.findByTeamIdAndSlugs` * Update schema and relations * Update types and schemas * Add more methods to IntegrationAttributeSyncRepository * Add more services to `IntegrationAttributeSyncService` - getById - Init updateIncludeRulesAndMappings * Refactor `getTeams.handler` to use repository * Create `createAttributeSync` trpc endpoint * Create `updateAttributeSync` trpc endpoint * Add router to trpc * Create attribute sync child components * Pass custom actions to `FormCard` * Create `IntegrationAttributeSyncCard` * Pass inital props via server side * Fix prop * Only refetch on mutation * Fixes * Add form error when duplicate field and attribute combo * Add `updateTransactionWithRUleAndMappings` logic * Adjust zod schemas * Service add `updateIncludeRulesAndMappings` * Pass orgId from server to component * Rename types * Add deleteById method to repository * Add name to integrationAttributeSync * Add deleteById method to service * Rename method * Add deleteAttributeSync trpc endpoint * Make the IntegrationAttributeSyncCard a dummy component * test: add tests for IntegrationAttributeSync feature Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Move creating a attribute sync record to the service * Add i18n strings * Safe select credential in find by id and team * Fix default credentialId value in form * Update repository return types * Add i18n string * Make credentialId optional for form schema * Fix label * Add cascade deletes * Add verification that syncs belong to org * Create mapper for repository output * Type fixes * Remove old test file * Pass `attributeOptions` from parent to children * Infer types from zod schema * Type fixes * Type fix * Clean up * Add i18n strings * Remove unused file * Address feedback * Add migration file * Address feedback * Add validation for new integration values * Remove unused router * Move away from z.infer to z.ZodType * Clean up comments * Type fix * Type fixes * Type fix * fix: add passthrough to syncFormDataSchema to preserve extra fields Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: remove incorrect test that expected extra fields to pass through syncFormDataSchema Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Add endpoint for SF to call * Create scratch org config * Create sf cli scripts * Create package logic * Update README * Remove unused file * Add indexes * Add aria label * Address feedback - consistent validation * Fix import paths for attribute types * Add `CredentialRepository.findByAppIdAndKeyValue` * Get credential by instance URL * Verify incoming sfdc orgId matches credential sfdc orgId * Rename method * Get user name integration syncs * refactor: change attributeSyncRules array to singular attributeSyncRule The database schema enforces a one-to-one relationship between IntegrationAttributeSync and AttributeSyncRule (via @unique constraint), and the UI only supports a single rule. This change makes the TypeScript type match the database schema and UI behavior. Changes: - Update IntegrationAttributeSync interface to use attributeSyncRule: AttributeSyncRule | null - Update mapper to return singular rule instead of wrapping in array - Update UI component to access sync.attributeSyncRule directly - Update IIntegrationAttributeSyncUpdateParams Omit type - Update tests to use attributeSyncRule: null instead of attributeSyncRules: [] Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Convert `membershipRepository.findAllByUserId` to a normal method * Add temp files to git ignore * Init and process team conditions * Biome formatting * Add `AttributeService` to get user attributes * Add DI container for AttributeService * AttributeSyncService evaluate attribute conditions * Create DI container for attributeSyncService * Return result for full condition * Evaluate if attribute sync should apply to user * Add method * Change PrismaAttributeOptionRepository to instance methods * Init AttributeSyncFieldMappingsService and process attribute syncs * Add AttributeSyncFieldMappingService DI container * Refactor orgId to organizationId * Add membership validation to sync field service * AttributeSYncFieldMappingService use repository methods * AttributeSyncFieldMappingService.processMappings add mapping logic * Add DI tokens * user-sync endpoint to implement attribute syncing * Validate team belongs to org for rule * test: add tests for AttributeSyncRuleService, AttributeSyncFieldMappingService, and AttributeService Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Remove duplicate migration file * Fix merge conflict * fix: resolve type errors in attribute sync feature (#26814) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Rename variable * Fix log typo * Fix file name * Add error logging * Use credential teamId * fix: add missing mockTeamRepository and team validation to tests Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Fix naming * Fix file import * Type fix * Pass MembershipRepository as a dep in AttributeSyncFieldMappingService * Type fix * fix: add mockMembershipRepository to AttributeSyncFieldMappingService tests Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Update packages/app-store/salesforce/api/user-sync.ts Add error handling when getting orgId from stored salesforce id Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: address Cubic code review comments - PrismaAttributeRepository: use nested select instead of include: true for options - CredentialRepository: use this.primaClient instead of global prisma, use select instead of include for relations - AttributeSyncFieldMappingService: optimize O(n*m) complexity with Map lookup for O(1) access Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Fix typo in CredentialRepository * Update README * Update sfdx-project * Add SFDC package tests * fix: improve Salesforce Apex test assertions to verify actual behavior - Enhanced CalComHttpMock to track HTTP callout invocations and capture requests - Updated UserUpdateHandlerTest to verify HTTP callouts are made with correct data - Updated CalComCalloutQueueableTest to verify HTTP callouts are made correctly - Replaced System.assert(true, ...) with meaningful assertions that verify: - Correct number of HTTP callouts - Request body contains expected fields - Request method is POST Addresses Cubic AI review feedback (confidence 9/10 issues only) Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
unknown
Devin AI
Hariom Balhara
cubic-dev-ai[bot]
parent
8c8a5b4ae9
commit
6501743e5e
@@ -2,4 +2,5 @@ src/gql/fragment-masking.ts
|
||||
# src/gql/gql.ts
|
||||
src/gql/graphql.ts
|
||||
src/gql/index.ts
|
||||
schema.graphql
|
||||
schema.graphql
|
||||
sfdc-package/.sf
|
||||
|
||||
@@ -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 <org-alias>
|
||||
sf apex run test --test-level RunLocalTests --wait 10 --target-org <org-alias>
|
||||
```
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
.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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"defaultdevhubusername": "DevHub"
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
@isTest
|
||||
private class CalComCalloutQueueableTest {
|
||||
|
||||
@isTest
|
||||
static void testQueueableExecutionSuccess() {
|
||||
CalComHttpMock.resetState();
|
||||
|
||||
List<CalComCalloutQueueable.UserChangePayload> 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<HttpRequest> 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<CalComCalloutQueueable.UserChangePayload> 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<CalComCalloutQueueable.UserChangePayload> 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<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();
|
||||
|
||||
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<String, Object>{'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<CalComCalloutQueueable.UserChangePayload> createTestPayloads(Integer count) {
|
||||
List<CalComCalloutQueueable.UserChangePayload> payloads = new List<CalComCalloutQueueable.UserChangePayload>();
|
||||
|
||||
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<String, Object>{
|
||||
'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;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
@isTest
|
||||
public class CalComHttpMock implements HttpCalloutMock {
|
||||
|
||||
private Integer statusCode;
|
||||
private String responseBody;
|
||||
|
||||
private static Integer callCount = 0;
|
||||
private static List<HttpRequest> capturedRequests = new List<HttpRequest>();
|
||||
|
||||
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<HttpRequest> getCapturedRequests() {
|
||||
return capturedRequests;
|
||||
}
|
||||
|
||||
public static void resetState() {
|
||||
callCount = 0;
|
||||
capturedRequests = new List<HttpRequest>();
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
+140
@@ -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<Id, User> oldMap = new Map<Id, User>();
|
||||
Map<Id, User> newMap = new Map<Id, User>();
|
||||
|
||||
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<HttpRequest> 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<Id, User> oldMap = new Map<Id, User>();
|
||||
Map<Id, User> newMap = new Map<Id, User>();
|
||||
|
||||
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<User> 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<Id, User> oldMap = new Map<Id, User>();
|
||||
Map<Id, User> newMap = new Map<Id, User>();
|
||||
|
||||
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<Id, User> oldMap = new Map<Id, User>();
|
||||
Map<Id, User> newMap = new Map<Id, User>();
|
||||
|
||||
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<HttpRequest> 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<Id, User> oldMap = new Map<Id, User>();
|
||||
Map<Id, User> newMap = new Map<Id, User>();
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<apiVersion>64.0</apiVersion>
|
||||
<status>Active</status>
|
||||
</ApexClass>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<AttributeService>(attributeServiceModule.token);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
values: Set<string>;
|
||||
};
|
||||
|
||||
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<Record<string, UserAttribute>> {
|
||||
const attributeOptionsAssignedToUser =
|
||||
await this.deps.attributeToUserRepository.findManyIncludeAttribute({
|
||||
member: { userId, teamId: orgId },
|
||||
});
|
||||
|
||||
const userAttributes: Record<string, UserAttribute> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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"]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
return delegationUserCredentials.map(
|
||||
({ delegationCredentialId, ...rest }) => {
|
||||
return {
|
||||
...rest,
|
||||
// We queried only those where delegationCredentialId is not null
|
||||
|
||||
delegationCredentialId: delegationCredentialId!,
|
||||
};
|
||||
});
|
||||
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<string, unknown>;
|
||||
const filteredKey = keyFields.reduce((acc, field) => {
|
||||
if (field in key) {
|
||||
acc[field] = key[field];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, unknown>);
|
||||
|
||||
return { ...credential, key: filteredKey };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
+23
@@ -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 };
|
||||
@@ -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 };
|
||||
+16
@@ -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<AttributeSyncFieldMappingService>(
|
||||
attributeSyncFieldMappingServiceModule.token
|
||||
);
|
||||
}
|
||||
+31
@@ -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 };
|
||||
+14
@@ -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<AttributeSyncRuleService>(attributeSyncRuleServiceModule.token);
|
||||
}
|
||||
@@ -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 };
|
||||
+23
@@ -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 };
|
||||
+2
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
+23
-6
@@ -123,14 +123,31 @@ export interface IUpdateAttributeSyncInput {
|
||||
}
|
||||
|
||||
export interface IIntegrationAttributeSyncRepository {
|
||||
getByOrganizationId(organizationId: number): Promise<IntegrationAttributeSync[]>;
|
||||
getByOrganizationId(
|
||||
organizationId: number
|
||||
): Promise<IntegrationAttributeSync[]>;
|
||||
getById(id: string): Promise<IntegrationAttributeSync | null>;
|
||||
getSyncFieldMappings(integrationAttributeSyncId: string): Promise<AttributeSyncFieldMapping[]>;
|
||||
getMappedAttributeIdsByOrganization(organizationId: number, excludeSyncId?: string): Promise<string[]>;
|
||||
getAttributeIdsByOrganization(organizationId: number, attributeIds: string[]): Promise<string[]>;
|
||||
create(params: IIntegrationAttributeSyncCreateParams): Promise<IntegrationAttributeSync>;
|
||||
updateTransactionWithRuleAndMappings(params: IIntegrationAttributeSyncUpdateParams): Promise<void>;
|
||||
getSyncFieldMappings(
|
||||
integrationAttributeSyncId: string
|
||||
): Promise<AttributeSyncFieldMapping[]>;
|
||||
getMappedAttributeIdsByOrganization(
|
||||
organizationId: number,
|
||||
excludeSyncId?: string
|
||||
): Promise<string[]>;
|
||||
getAttributeIdsByOrganization(
|
||||
organizationId: number,
|
||||
attributeIds: string[]
|
||||
): Promise<string[]>;
|
||||
create(
|
||||
params: IIntegrationAttributeSyncCreateParams
|
||||
): Promise<IntegrationAttributeSync>;
|
||||
updateTransactionWithRuleAndMappings(
|
||||
params: IIntegrationAttributeSyncUpdateParams
|
||||
): Promise<void>;
|
||||
deleteById(id: string): Promise<void>;
|
||||
getAllByCredentialId(
|
||||
credentialId: number
|
||||
): Promise<IntegrationAttributeSync[]>;
|
||||
}
|
||||
|
||||
export interface IIntegrationAttributeSyncUpdateParams {
|
||||
|
||||
+21
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+560
@@ -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<typeof vi.fn>;
|
||||
createManySkipDuplicates: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockAttributeRepository: {
|
||||
findManyByIdsAndOrgIdWithOptions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockAttributeOptionRepository: {
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockMembershipRepository: {
|
||||
findUniqueByUserIdAndTeamId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+271
@@ -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<string, string>;
|
||||
}): Promise<void> {
|
||||
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<string, unknown>;
|
||||
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<Array<{ memberId: number; attributeOptionId: string }>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+708
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
let mockAttributeToUserRepository: {
|
||||
findManyIncludeAttribute: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockAttributeService: {
|
||||
getUsersAttributesByOrgMembershipId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<boolean[]> {
|
||||
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<UserAttribute, { type: "MULTI_SELECT" }>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
-1
@@ -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<typeof vi.fn>;
|
||||
deleteById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockTeamRepository: {
|
||||
findTeamsNotBelongingToOrgByIds: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+48
-1
@@ -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<void> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<UserType, "movedToProfileId"> }) {
|
||||
async isMovedToAProfile({
|
||||
user,
|
||||
}: {
|
||||
user: Pick<UserType, "movedToProfileId">;
|
||||
}) {
|
||||
return !!user.movedToProfileId;
|
||||
}
|
||||
|
||||
@@ -519,13 +560,9 @@ export class UserRepository {
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async enrichUserWithTheProfile<T extends { username: string | null; id: number }>({
|
||||
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<T extends { id: number; username: string | null }>(
|
||||
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<number, UserProfile>();
|
||||
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<T extends { id: number; username: string | null }>(
|
||||
async enrichUsersWithTheirProfileExcludingOrgMetadata<
|
||||
T extends { id: number; username: string | null }
|
||||
>(
|
||||
users: T[]
|
||||
): Promise<
|
||||
Array<
|
||||
@@ -723,7 +767,9 @@ export class UserRepository {
|
||||
});
|
||||
}
|
||||
|
||||
enrichUserWithItsProfileBuiltFromUser<T extends { id: number; username: string | null }>({
|
||||
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<Prisma.UserCreateInput, "password" | "organization" | "movedToProfile"> & {
|
||||
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;
|
||||
const { email, username, creationSource, locked, hashedPassword, ...rest } =
|
||||
data;
|
||||
|
||||
logger.info("create user", { email, username, organizationIdValue, locked });
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user