* 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>
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
import type {
|
|
AttributeId,
|
|
AttributeName,
|
|
BulkAttributeAssigner,
|
|
AttributeOptionAssignment,
|
|
} from "@calcom/app-store/routing-forms/types/types";
|
|
import { findAssignmentsForMember } from "@calcom/features/attributes/lib/utils";
|
|
import { PrismaAttributeOptionRepository } from "@calcom/features/attributes/repositories/PrismaAttributeOptionRepository";
|
|
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
|
|
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
|
|
import {
|
|
doesSupportMultipleValues,
|
|
isAssignmentForLockedAttribute,
|
|
isAssignmentForTheSamePool,
|
|
isAssignmentSame,
|
|
buildSlugFromValue,
|
|
canSetValueBeyondOptions,
|
|
} from "./assignValueToUserUtils";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
|
|
|
|
type AttributesIncludingOptions = Awaited<ReturnType<typeof findAttributesByName>>[number];
|
|
type AttributeOptionsToAssign = {
|
|
attribute: { id: AttributeId; isLocked: boolean };
|
|
optionsToAssign: { id: string | null; label: string }[];
|
|
};
|
|
|
|
type AttributeOptionsToAssignWithIdEnsured = {
|
|
attribute: { id: AttributeId; isLocked: boolean };
|
|
optionsToAssign: { id: string; label: string }[];
|
|
};
|
|
type AttributeLabelToValueMap = Record<AttributeName, string | string[]>;
|
|
|
|
const findAttributesByName = async ({
|
|
orgId,
|
|
attributeNames,
|
|
}: {
|
|
orgId: number;
|
|
attributeNames: AttributeName[];
|
|
}) => {
|
|
const attributeRepo = new PrismaAttributeRepository(prisma);
|
|
const attributesFromDb = await attributeRepo.findManyByNamesAndOrgIdIncludeOptions({
|
|
attributeNames,
|
|
orgId,
|
|
});
|
|
|
|
return attributesFromDb.map((attribute) => {
|
|
const { name, options, ...rest } = attribute;
|
|
|
|
return {
|
|
...rest,
|
|
label: name,
|
|
options: options.map((option) => {
|
|
return {
|
|
...option,
|
|
label: option.value,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
};
|
|
|
|
const lookupByLabels = <TWithLabel extends { label: string }>({
|
|
labels,
|
|
items,
|
|
}: {
|
|
labels: string[];
|
|
items: TWithLabel[];
|
|
}) => {
|
|
const map = new Map<string, TWithLabel | null>();
|
|
labels.forEach((label) => {
|
|
map.set(label, items.find((item) => item.label.toLowerCase() === label.toLowerCase()) ?? null);
|
|
});
|
|
return map;
|
|
};
|
|
|
|
const lookupByLabel = <TWithLabel extends { label: string }>({
|
|
label,
|
|
items,
|
|
}: {
|
|
label: string;
|
|
items: TWithLabel[];
|
|
}) => {
|
|
const map = lookupByLabels({ labels: [label], items });
|
|
return map.get(label);
|
|
};
|
|
|
|
export const buildPrismaQueriesForAttributeOptionToUser = ({
|
|
existingAttributeOptionAssignments,
|
|
attributeOptionsToAssign,
|
|
updater,
|
|
memberId,
|
|
}: {
|
|
orgId: number;
|
|
memberId: number;
|
|
existingAttributeOptionAssignments: AttributeOptionAssignment[];
|
|
attributeOptionsToAssign: AttributeOptionsToAssignWithIdEnsured[];
|
|
updater: BulkAttributeAssigner;
|
|
}) => {
|
|
const assignmentsToUpdateIds: string[] = [];
|
|
if (!attributeOptionsToAssign.length) {
|
|
log.debug("No attribute options to assign, skipping");
|
|
return {
|
|
attributeToUserCreateManyInput: [],
|
|
attributeToUserUpdateManyInput: null,
|
|
attributeToUserDeleteQueries: {
|
|
locked: null,
|
|
unlocked: null,
|
|
},
|
|
};
|
|
}
|
|
const { attributeToUserCreateManyInput } = attributeOptionsToAssign.reduce(
|
|
(acc, attributeOptionsToAssign) => {
|
|
const existingAttributeOptionAssignment = existingAttributeOptionAssignments.find(
|
|
(existingAttributeOptionForMember) => existingAttributeOptionForMember.attributeOption.attribute.id
|
|
);
|
|
|
|
// Prevent overriding the only value for an unlocked attribute
|
|
if (
|
|
existingAttributeOptionAssignment &&
|
|
!isAssignmentForLockedAttribute({
|
|
assignment: existingAttributeOptionAssignment,
|
|
}) &&
|
|
!doesSupportMultipleValues({
|
|
attribute: existingAttributeOptionAssignment.attributeOption.attribute,
|
|
}) &&
|
|
!isAssignmentForTheSamePool({
|
|
assignment: existingAttributeOptionAssignment,
|
|
updater,
|
|
})
|
|
) {
|
|
console.warn(
|
|
`Attribute ${existingAttributeOptionAssignment.attributeOption.attribute.id} already assigned to user ${memberId} and is not a multi-select attribute. So, skipping`
|
|
);
|
|
return acc;
|
|
}
|
|
|
|
attributeOptionsToAssign.optionsToAssign.forEach((optionToAssign) => {
|
|
// Prevent unnecessary recreation of the assignment, to avoid resetting the "weight"
|
|
if (
|
|
existingAttributeOptionAssignment &&
|
|
isAssignmentSame({
|
|
existingAssignment: existingAttributeOptionAssignment,
|
|
newOption: optionToAssign,
|
|
})
|
|
) {
|
|
log.debug(
|
|
`Updating existing assignment ${existingAttributeOptionAssignment.id} as it has same value`
|
|
);
|
|
assignmentsToUpdateIds.push(existingAttributeOptionAssignment.id);
|
|
return;
|
|
}
|
|
|
|
acc.attributeToUserCreateManyInput.push({
|
|
memberId,
|
|
attributeOptionId: optionToAssign.id,
|
|
...("dsyncId" in updater
|
|
? { createdByDSyncId: updater.dsyncId, createdById: null }
|
|
: {
|
|
createdById: updater.userId,
|
|
createdByDSyncId: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
return acc;
|
|
},
|
|
{
|
|
attributeToUserCreateManyInput: [] as {
|
|
memberId: number;
|
|
attributeOptionId: string;
|
|
createdByDSyncId: string | null;
|
|
createdById: number | null;
|
|
}[],
|
|
}
|
|
);
|
|
|
|
const lockedAttributeIds = attributeOptionsToAssign
|
|
.filter((option) => option.attribute.isLocked)
|
|
.map((option) => option.attribute.id);
|
|
|
|
const unlockedAttributeIds = attributeOptionsToAssign
|
|
.filter((option) => !option.attribute.isLocked)
|
|
.map((option) => option.attribute.id);
|
|
|
|
const unlockedAttributesAssignmentIdsInThePool = existingAttributeOptionAssignments
|
|
.filter((assignment) => {
|
|
return (
|
|
isAssignmentForTheSamePool({
|
|
assignment,
|
|
updater,
|
|
}) && unlockedAttributeIds.includes(assignment.attributeOption.attribute.id)
|
|
);
|
|
})
|
|
.map((existingAttributeOptionForMember) => existingAttributeOptionForMember.id);
|
|
|
|
const unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated =
|
|
unlockedAttributesAssignmentIdsInThePool.filter((id) => !assignmentsToUpdateIds.includes(id));
|
|
const attributeToUserDeleteQueryWhereClauseForUnlockedAttributes =
|
|
unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated.length
|
|
? scopedWhereClause({
|
|
memberId,
|
|
whereClause: {
|
|
id: {
|
|
in: unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated,
|
|
},
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const attributeToUserDeleteQueryWhereClauseForLockedAttributes = lockedAttributeIds.length
|
|
? scopedWhereClause({
|
|
memberId,
|
|
whereClause: {
|
|
...(assignmentsToUpdateIds.length
|
|
? {
|
|
id: {
|
|
notIn: assignmentsToUpdateIds,
|
|
},
|
|
}
|
|
: null),
|
|
// No matter who assigned the attribute(Cal.com or SCIM), we delete it because the source of truth is SCIM and only one SCIM can be be source of truth at one time.
|
|
attributeOption: {
|
|
attribute: {
|
|
id: {
|
|
in: lockedAttributeIds,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const attributeToUserUpdateManyInput = assignmentsToUpdateIds.length
|
|
? {
|
|
where: scopedWhereClause({
|
|
memberId,
|
|
whereClause: {
|
|
id: {
|
|
in: assignmentsToUpdateIds,
|
|
},
|
|
},
|
|
}),
|
|
data: {
|
|
...("dsyncId" in updater
|
|
? { updatedByDSyncId: updater.dsyncId, updatedById: null }
|
|
: {
|
|
updatedById: updater.userId,
|
|
updatedByDSyncId: null,
|
|
}),
|
|
},
|
|
}
|
|
: null;
|
|
|
|
log.debug("buildPrismaQueriesForAttributeOptionToUser", {
|
|
assignmentsToUpdateIds,
|
|
lockedAttributeIds,
|
|
unlockedAttributesAssignmentIdsInThePool,
|
|
unlockedAttributeIds,
|
|
existingAttributeOptionAssignments,
|
|
updater,
|
|
attributeToUserDeleteQueryWhereClauseForLockedAttributes: safeStringify(
|
|
attributeToUserDeleteQueryWhereClauseForLockedAttributes
|
|
),
|
|
attributeToUserDeleteQueryWhereClauseForUnlockedAttributes: safeStringify(
|
|
attributeToUserDeleteQueryWhereClauseForUnlockedAttributes
|
|
),
|
|
attributeToUserCreateManyInput: safeStringify(attributeToUserCreateManyInput),
|
|
attributeToUserUpdateManyInput,
|
|
});
|
|
|
|
return {
|
|
attributeToUserUpdateManyInput,
|
|
attributeToUserCreateManyInput,
|
|
attributeToUserDeleteQueries: {
|
|
locked: attributeToUserDeleteQueryWhereClauseForLockedAttributes,
|
|
unlocked: attributeToUserDeleteQueryWhereClauseForUnlockedAttributes,
|
|
},
|
|
};
|
|
|
|
function scopedWhereClause<TWhereClause extends object | null>({
|
|
memberId,
|
|
whereClause,
|
|
}: {
|
|
memberId: number;
|
|
whereClause: TWhereClause;
|
|
}) {
|
|
return {
|
|
...whereClause,
|
|
memberId,
|
|
};
|
|
}
|
|
};
|
|
|
|
const buildPrismaQueryForAttributeOptionCreation = ({
|
|
optionsToCreate,
|
|
}: {
|
|
optionsToCreate: Record<AttributeId, string[]>;
|
|
}) => {
|
|
const attributeOptionCreateManyInput = Object.entries(optionsToCreate).map(([attributeId, options]) =>
|
|
options.map((option) => ({
|
|
attributeId,
|
|
value: option,
|
|
slug: buildSlugFromValue({ value: option }),
|
|
}))
|
|
);
|
|
return attributeOptionCreateManyInput.flat();
|
|
};
|
|
|
|
const createMissingOptionsAndReturnAlongWithExisting = async <
|
|
TattributeOptionsToAssign extends AttributeOptionsToAssign
|
|
>({
|
|
attributeOptionsToAssignIncludingNonExistentOptions,
|
|
orgId,
|
|
}: {
|
|
attributeOptionsToAssignIncludingNonExistentOptions: TattributeOptionsToAssign[];
|
|
orgId: number;
|
|
}) => {
|
|
const attributeOptionCreateManyInput = buildPrismaQueryForAttributeOptionCreation({
|
|
optionsToCreate: attributeOptionsToAssignIncludingNonExistentOptions.reduce(
|
|
(acc, attributeOptionsToAssign) => {
|
|
const { attribute, optionsToAssign } = attributeOptionsToAssign;
|
|
acc[attribute.id] = optionsToAssign.filter((option) => !option.id).map((option) => option.label);
|
|
return acc;
|
|
},
|
|
{} as Record<AttributeId, string[]>
|
|
),
|
|
});
|
|
|
|
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 attributeOptionRepository.findMany({
|
|
orgId,
|
|
})
|
|
).map((attributeOption) => ({
|
|
...attributeOption,
|
|
label: attributeOption.value,
|
|
}));
|
|
|
|
return attributeOptionsToAssignIncludingNonExistentOptions.map(({ attribute, optionsToAssign }) => {
|
|
const optionsWithIdsOfTheSameAttribute = optionsToAssign
|
|
.map((option) =>
|
|
allAttributeOptions.find(
|
|
(attributeOption) =>
|
|
attributeOption.attributeId === attribute.id &&
|
|
attributeOption.label.toLowerCase() === option.label.toLowerCase()
|
|
)
|
|
)
|
|
.filter((option): option is NonNullable<typeof option> => option !== null);
|
|
return {
|
|
attribute,
|
|
optionsToAssign: optionsWithIdsOfTheSameAttribute,
|
|
};
|
|
});
|
|
};
|
|
|
|
const buildAttributeOptionsToAssign = async ({
|
|
attributeLabelToValueMap,
|
|
allAttributesBeingAssigned,
|
|
orgId,
|
|
}: {
|
|
attributeLabelToValueMap: AttributeLabelToValueMap;
|
|
allAttributesBeingAssigned: AttributesIncludingOptions[];
|
|
orgId: number;
|
|
}) => {
|
|
const attributeOptionsToAssignIncludingNonExistentOptions = Object.entries(attributeLabelToValueMap)
|
|
.map(([attributeLabel, value]) => {
|
|
const valueAsArray = value instanceof Array ? value : [value];
|
|
const attribute = lookupByLabel({ label: attributeLabel, items: allAttributesBeingAssigned });
|
|
if (!attribute) {
|
|
console.warn(`Attribute ${attributeLabel} not found, will not be assigned to user`);
|
|
return null;
|
|
}
|
|
|
|
const labelToFoundOptionMap = lookupByLabels({ labels: valueAsArray, items: attribute.options });
|
|
const labelsEntries = Array.from(labelToFoundOptionMap.entries());
|
|
const optionsToAssign = labelsEntries
|
|
.filter(([_, option]) => {
|
|
return canSetValueBeyondOptions({ attribute }) ? true : !!option;
|
|
})
|
|
.map(([label]) => {
|
|
const foundLabel = labelToFoundOptionMap.get(label) ?? null;
|
|
return {
|
|
label,
|
|
...foundLabel,
|
|
};
|
|
});
|
|
|
|
return {
|
|
attribute,
|
|
optionsToAssign: optionsToAssign.map((option) => ({
|
|
label: option.label,
|
|
id: option.id ?? null,
|
|
})),
|
|
};
|
|
})
|
|
.filter((option): option is NonNullable<typeof option> => option !== null);
|
|
|
|
return createMissingOptionsAndReturnAlongWithExisting({
|
|
attributeOptionsToAssignIncludingNonExistentOptions,
|
|
orgId,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* For a user in an org, it assigns all the attributes with their values as per the attributeLabelToValueMap.
|
|
*/
|
|
export const assignValueToUserInOrgBulk = async ({
|
|
orgId,
|
|
userId,
|
|
attributeLabelToValueMap,
|
|
updater,
|
|
}: {
|
|
orgId: number;
|
|
userId: number;
|
|
attributeLabelToValueMap: AttributeLabelToValueMap;
|
|
updater: BulkAttributeAssigner;
|
|
}) => {
|
|
const membershipRepository = new MembershipRepository();
|
|
const membership = await membershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
|
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
|
|
if (!membership) {
|
|
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
|
|
return defaultReturn;
|
|
}
|
|
|
|
const { id: memberId } = membership;
|
|
const attributeNames = Object.keys(attributeLabelToValueMap);
|
|
|
|
const allAttributesBeingAssigned = await findAttributesByName({
|
|
orgId,
|
|
attributeNames,
|
|
});
|
|
|
|
if (!allAttributesBeingAssigned.length) {
|
|
console.warn(`None of the attributes ${attributeNames.join(", ")} found in org ${orgId}`);
|
|
return defaultReturn;
|
|
}
|
|
|
|
const attributeOptionsToAssign = await buildAttributeOptionsToAssign({
|
|
attributeLabelToValueMap,
|
|
allAttributesBeingAssigned,
|
|
orgId,
|
|
});
|
|
|
|
// We need to fetch all the assigned options even those that aren't being set directly in call.
|
|
// We need to decide on the fate of all existing assignments
|
|
const existingAttributeOptionAssignments = await findAssignmentsForMember({ memberId });
|
|
|
|
const { attributeToUserUpdateManyInput, attributeToUserCreateManyInput, attributeToUserDeleteQueries } =
|
|
buildPrismaQueriesForAttributeOptionToUser({
|
|
orgId,
|
|
memberId,
|
|
attributeOptionsToAssign,
|
|
existingAttributeOptionAssignments,
|
|
updater,
|
|
});
|
|
|
|
const { numOfAttributeOptionsDeleted, numOfAttributeOptionsSet, numOfAttributeOptionsUpdated } =
|
|
await prisma.$transaction(async (tx) => {
|
|
// First delete all existing options, to allow them to be set again.
|
|
const deleteQueriesResult = await Promise.all(
|
|
Object.entries(attributeToUserDeleteQueries).map(([_, attributeToUserDeleteQuery]) =>
|
|
attributeToUserDeleteQuery
|
|
? tx.attributeToUser.deleteMany({
|
|
where: attributeToUserDeleteQuery,
|
|
})
|
|
: Promise.resolve({ count: 0 })
|
|
)
|
|
);
|
|
|
|
// Then set the new options.
|
|
const { count: numOfAttributeOptionsSet } = await tx.attributeToUser.createMany({
|
|
data: attributeToUserCreateManyInput,
|
|
// In case the values already exist, we skip them instead of throwing an error.
|
|
skipDuplicates: true,
|
|
});
|
|
|
|
let numOfAttributeOptionsUpdated = 0;
|
|
if (attributeToUserUpdateManyInput) {
|
|
({ count: numOfAttributeOptionsUpdated } = await tx.attributeToUser.updateMany({
|
|
where: attributeToUserUpdateManyInput?.where,
|
|
data: attributeToUserUpdateManyInput?.data,
|
|
}));
|
|
}
|
|
|
|
const numOfAttributeOptionsDeleted = deleteQueriesResult.reduce(
|
|
(acc, query) => acc + (query.count ?? 0),
|
|
0
|
|
);
|
|
|
|
return {
|
|
numOfAttributeOptionsDeleted,
|
|
numOfAttributeOptionsSet,
|
|
numOfAttributeOptionsUpdated,
|
|
};
|
|
});
|
|
|
|
log.debug({
|
|
numOfAttributeOptionsSet,
|
|
numOfAttributeOptionsDeleted,
|
|
numOfAttributeOptionsUpdated,
|
|
});
|
|
|
|
return {
|
|
numOfAttributeOptionsSet,
|
|
numOfAttributeOptionsDeleted,
|
|
numOfAttributeOptionsUpdated,
|
|
};
|
|
};
|