feat: Create Integration Attribute Sync records (#26007)
* 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 indexes * Add aria label * Address feedback - consistent validation * Fix import paths for attribute types * 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> * feat: implement FeatureOptInService (#25805) * feat: implement FeatureOptInService WIP * clean up * feat: consolidate feature repositories and add updateFeatureForUser - Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam) - Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository - Update FeatureOptInService to use only FeaturesRepository - Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService - Update _router.ts to remove PrismaFeatureOptInRepository usage - Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts - Update features.repository.interface.ts and features.repository.mock.ts - Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState - Update service.integration-test.ts to use FeaturesRepository Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: rename updateFeatureForUser to setUserFeatureState Rename to match the convention used for setTeamFeatureState Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState * fix integration tests * clean up logics * update services and router * refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array - Renamed getUserFeatureState to getUserFeatureStates - Renamed getTeamFeatureState to getTeamFeatureStates - Changed parameter from featureId: string to featureIds: string[] - Changed return type from FeatureState to Record<string, FeatureState> - Updated FeatureOptInService to use the new batch methods - Added tests for querying multiple features in a single call - Optimized listFeaturesForTeam to fetch all feature states in one query Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add getFeatureStateForTeams for batch querying multiple teams - Added getFeatureStateForTeams method to query a single feature across multiple teams in one call - Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method - Replaces N+1 queries with a single database query for team states - Added comprehensive integration tests for the new method Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: combine org and team state queries into single call - Include orgId in the teamIds array passed to getFeatureStateForTeams - Extract org state and team states from the combined result - Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use team.isOrganization and clarify computeEffectiveState comment Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: use MembershipRepository.findAllByUserId with isOrganization Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * feat: add featureId validation using isOptInFeature type guard Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * less queries * add fallback value * fix type error * move files * add autoOptInFeatures column * use autoOptInFeatures flag within FeatureOptInService * add setUserAutoOptIn and setTeamAutoOptIn * fix computeEffectiveState logic * rewrite computeEffectiveState * clean up integration tests * clean up in afterEach * fix type error * refactor: use FeaturesRepository methods instead of direct Prisma calls Replace all manual userFeatures and teamFeatures Prisma operations with the new setUserFeatureState and setTeamFeatureState repository methods. Changes include: - Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam) - Test fixtures and integration tests - Playwright fixtures - Development scripts This ensures consistent feature flag management through the repository pattern and supports the new tri-state semantics (enabled/disabled/inherit). Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * clean up * fix the logic * extract some logic into applyAutoOptIn() * remove wrong code * refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union - Convert multiple positional parameters to single object parameter - Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit - Update all callers across repository, service, handlers, fixtures, and tests * fix type error * use Promise.all * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Prevent duplicate field and attribute mappings * Add validation that attribute belongs to the org * fix: address Cubic AI review feedback - Add @@index([credentialId]) to IntegrationAttributeSync model for efficient cascade deletes and credential-based queries (confidence: 9/10) - Fix translation key from 'credential_required' to 'attribute_sync_credential_required' to match existing locale definition (confidence: 10/10) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: address remaining cubic review feedback - RuleBuilder.tsx: Use unique IDs for React keys instead of array index to prevent reconciliation bugs when removing conditions - updateAttributeSync.handler.ts: Add early organization check before service calls for consistency - createAttributeSync.handler.ts: Add CredentialNotFoundError class for type-safe error handling instead of string matching - IntegrationAttributeSyncService.test.ts: Replace expect.fail() with proper Vitest rejects.toSatisfy() pattern Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Pull test file from main --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
co-authored by
joe@cal.com
Devin AI
Eunjae Lee
parent
9202028ab6
commit
8dbe3848e7
+62
@@ -0,0 +1,62 @@
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
|
||||
import IntegrationAttributeSyncView from "@calcom/features/ee/integration-attribute-sync/IntegrationAttributeSyncView";
|
||||
import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { validateUserHasOrgPerms } from "../../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("attribute_sync"),
|
||||
(t) => t("attribute_sync_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/attributes/sync"
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const session = await validateUserHasOrgPerms({
|
||||
permission: "organization.attributes.create",
|
||||
fallbackRoles: ["ADMIN", "OWNER"],
|
||||
redirectTo: "/settings/my-account/profile",
|
||||
});
|
||||
|
||||
const organizationId = session.user.org.id;
|
||||
|
||||
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const attributeRepo = new PrismaAttributeRepository(prisma);
|
||||
|
||||
const [credentialData, integrationAttributeSyncs, organizationTeams, attributes] = await Promise.all([
|
||||
integrationAttributeSyncService.getEnabledAppCredentials(organizationId),
|
||||
integrationAttributeSyncService.getAllIntegrationAttributeSyncs(organizationId),
|
||||
teamRepository.findAllByParentId({
|
||||
parentId: organizationId,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsHeader title={t("attribute_sync")} description={t("attribute_sync_description")}>
|
||||
<IntegrationAttributeSyncView
|
||||
credentialsData={credentialData}
|
||||
initialIntegrationAttributeSyncs={integrationAttributeSyncs}
|
||||
organizationTeams={organizationTeams}
|
||||
attributes={attributes}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { attributeSyncRouter } from "@calcom/trpc/server/routers/viewer/attribute-sync/_router";
|
||||
|
||||
export default createNextApiHandler(attributeSyncRouter);
|
||||
@@ -374,6 +374,7 @@
|
||||
"done": "Done",
|
||||
"all_done": "All done!",
|
||||
"all": "All",
|
||||
"any": "Any",
|
||||
"yours": "Your account",
|
||||
"available_apps": "Available Apps",
|
||||
"available_apps_lower_case": "Available apps",
|
||||
@@ -3101,6 +3102,47 @@
|
||||
"back_to_attributes": "Back to attributes",
|
||||
"delete_attribute": "Are you sure you want to delete this option?",
|
||||
"delete_attribute_description": "This option is assigned to {{numberOfUsers}} members. Deleting it will remove it from their profile.",
|
||||
"attribute_sync": "Attribute Sync",
|
||||
"attribute_sync_description": "Setup attribute syncing with 3rd party integrations",
|
||||
"attribute_sync_new_integration_sync": "New Integration Sync",
|
||||
"attribute_sync_credential": "Credential",
|
||||
"attribute_sync_credential_required": "Credential is required",
|
||||
"attribute_sync_credential_validation": "Select a credential for the attribute sync",
|
||||
"attribute_sync_select_credential": "Select a credential...",
|
||||
"attribute_sync_credential_description": "Choose which integration credential to use for syncing attributes",
|
||||
"attribute_sync_user_filter_rules": "User Filter Rules",
|
||||
"attribute_sync_user_filter_rules_description": "Define which users to sync based on team membership or attributes",
|
||||
"attribute_sync_field_mappings": "Field Mappings",
|
||||
"attribute_sync_duplicate_field_mapping": "Duplicate field name and attribute combination found",
|
||||
"attribute_sync_duplicate_attribute_mapping": "Each attribute can only be mapped once",
|
||||
"attribute_sync_field_mappings_description": "Map integration field names to Cal.com attributes for syncing",
|
||||
"attribute_sync_created_successfully": "Attribute sync created successfully",
|
||||
"attribute_sync_updated_successfully": "Attribute sync updated successfully",
|
||||
"attribute_sync_deleted_successfully": "Attribute sync deleted successfully",
|
||||
"attribute_sync_delete_title": "Delete Attribute Sync",
|
||||
"attribute_sync_delete_confirmation": "Are you sure you want to delete this sync? This action cannot be undone.",
|
||||
"attribute_sync_no_mappings": "No field mappings added. Click below to add your first mapping.",
|
||||
"attribute_sync_add_mapping": "Add mapping",
|
||||
"attribute_sync_field_placeholder": "e.g., Department, Title...",
|
||||
"attribute_sync_select_attribute": "Select Cal.com attribute...",
|
||||
"attribute_sync_add_new": "Add new sync",
|
||||
"attribute_sync_select_teams": "Select team(s)...",
|
||||
"attribute_sync_select_attribute_first": "Select an attribute first",
|
||||
"attribute_sync_select_values": "Select value(s)...",
|
||||
"attribute_sync_enter_text_value": "Enter text value...",
|
||||
"attribute_sync_enter_number_value": "Enter number value...",
|
||||
"attribute_sync_sync_users_where": "Sync users where",
|
||||
"attribute_sync_conditions_match": "conditions match:",
|
||||
"attribute_sync_no_conditions": "No conditions added. Click below to add your first condition.",
|
||||
"attribute_sync_add_condition": "Add condition",
|
||||
"attribute_sync_operator_is_any_of": "is any of",
|
||||
"attribute_sync_operator_is_not_any_of": "is not any of",
|
||||
"attribute_sync_operator_is": "is",
|
||||
"attribute_sync_operator_is_not": "is not",
|
||||
"attribute_sync_operator_includes_any_of": "includes any of",
|
||||
"attribute_sync_operator_does_not_include": "does not include",
|
||||
"attribute_sync_operator_equals": "equals",
|
||||
"attribute_sync_operator_not_equals": "not equals",
|
||||
"disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type",
|
||||
"disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.",
|
||||
"disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type",
|
||||
|
||||
@@ -300,4 +300,33 @@ export class CredentialRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByTeamIdAndSlugs({ teamId, slugs }: { teamId: number; slugs: string[] }) {
|
||||
return this.primaClient.credential.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
appId: {
|
||||
in: slugs,
|
||||
},
|
||||
},
|
||||
select: { ...safeCredentialSelect, team: { select: { name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
findByIdAndTeamId({ id, teamId }: { id: number; teamId: number }) {
|
||||
return this.primaClient.credential.findFirst({
|
||||
where: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
...safeCredentialSelect,
|
||||
app: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import EditIntegrationAttributeSyncCard from "./components/EditIntegrationAttributeSyncCard";
|
||||
import NewIntegrationAttributeSyncCard from "./components/NewIntegrationAttributeSyncCard";
|
||||
import type { IntegrationAttributeSync } from "./repositories/IIntegrationAttributeSyncRepository";
|
||||
|
||||
interface IIntegrationAttributeSyncViewProps {
|
||||
credentialsData: {
|
||||
id: number;
|
||||
type: string;
|
||||
team: {
|
||||
name: string;
|
||||
} | null;
|
||||
}[];
|
||||
initialIntegrationAttributeSyncs: IntegrationAttributeSync[];
|
||||
organizationTeams: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
attributes: Attribute[];
|
||||
organizationId: number;
|
||||
}
|
||||
|
||||
const IntegrationAttributeSyncView = (props: IIntegrationAttributeSyncViewProps) => {
|
||||
const { credentialsData, initialIntegrationAttributeSyncs, organizationTeams, attributes, organizationId } =
|
||||
props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [showNewSync, setShowNewSync] = useState(false);
|
||||
|
||||
const { data: integrationAttributeSyncs } = trpc.viewer.attributeSync.getAllAttributeSyncs.useQuery(
|
||||
{},
|
||||
{
|
||||
initialData: initialIntegrationAttributeSyncs,
|
||||
}
|
||||
);
|
||||
|
||||
const credentialOptions =
|
||||
credentialsData?.map((cred) => ({
|
||||
value: String(cred.id),
|
||||
label: `${cred.type} ${cred.team?.name ? `(${cred.team.name})` : ""}`,
|
||||
})) ?? [];
|
||||
|
||||
const teamOptions =
|
||||
organizationTeams?.map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
})) ?? [];
|
||||
|
||||
const attributeOptions =
|
||||
attributes?.map((attr) => ({
|
||||
value: attr.id,
|
||||
label: attr.name,
|
||||
})) ?? [];
|
||||
|
||||
const handleAddNewSync = () => {
|
||||
setShowNewSync(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowNewSync(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{integrationAttributeSyncs && integrationAttributeSyncs.length > 0 && (
|
||||
<div className="mb-4 space-y-2">
|
||||
{integrationAttributeSyncs.map((sync) => (
|
||||
<EditIntegrationAttributeSyncCard
|
||||
key={sync.id}
|
||||
sync={sync}
|
||||
credentialOptions={credentialOptions}
|
||||
teamOptions={teamOptions}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewSync && (
|
||||
<NewIntegrationAttributeSyncCard
|
||||
credentialOptions={credentialOptions}
|
||||
teamOptions={teamOptions}
|
||||
attributes={attributes}
|
||||
attributeOptions={attributeOptions}
|
||||
organizationId={organizationId}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
<Button StartIcon="plus" onClick={handleAddNewSync}>
|
||||
{t("attribute_sync_add_new")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationAttributeSyncView;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { IntegrationAttributeSync, ISyncFormData } from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import IntegrationAttributeSyncCard from "./IntegrationAttributeSyncCard";
|
||||
import type { IIntegrationAttributeSyncCardProps } from "./IntegrationAttributeSyncCard";
|
||||
|
||||
type IEditIntegrationAttributeSyncCardProps = Pick<
|
||||
IIntegrationAttributeSyncCardProps,
|
||||
"credentialOptions" | "teamOptions" | "attributes" | "organizationId" | "attributeOptions"
|
||||
> & {
|
||||
sync: IntegrationAttributeSync;
|
||||
};
|
||||
|
||||
const EditIntegrationAttributeSyncCard = (props: IEditIntegrationAttributeSyncCardProps) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const updateMutation = trpc.viewer.attributeSync.updateAttributeSync.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
|
||||
showToast(t("attribute_sync_updated_successfully"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.viewer.attributeSync.deleteAttributeSync.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
|
||||
showToast(t("attribute_sync_deleted_successfully"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: ISyncFormData) => {
|
||||
updateMutation.mutate({ ...data });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
deleteMutation.mutate({ id: props.sync.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegrationAttributeSyncCard
|
||||
{...props}
|
||||
onSubmit={onSubmit}
|
||||
onDelete={onDelete}
|
||||
isSubmitting={updateMutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditIntegrationAttributeSyncCard;
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Input, Select, Switch } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { getDefaultFieldMapping } from "../lib/fieldMappingHelpers";
|
||||
import type {
|
||||
IFieldMappingFormState,
|
||||
IFieldMappingWithOptionalId,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
|
||||
type AttributeOptions = {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
|
||||
interface FieldMappingBuilderProps {
|
||||
value: IFieldMappingFormState;
|
||||
onChange: (value: IFieldMappingFormState) => void;
|
||||
attributeOptions: AttributeOptions;
|
||||
isLoadingAttributes?: boolean;
|
||||
}
|
||||
|
||||
interface MappingRowProps {
|
||||
mapping: IFieldMappingWithOptionalId;
|
||||
onChange: (mapping: IFieldMappingWithOptionalId) => void;
|
||||
onRemove: () => void;
|
||||
attributeOptions: AttributeOptions;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const MappingRow = ({ mapping, onChange, onRemove, attributeOptions, isLoading, error }: MappingRowProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const selectedAttribute = attributeOptions.find((opt) => opt.value === mapping.attributeId);
|
||||
|
||||
const handleFieldNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...mapping,
|
||||
integrationFieldName: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAttributeChange = (selected: { value: string; label: string } | null) => {
|
||||
if (!selected) return;
|
||||
onChange({
|
||||
...mapping,
|
||||
attributeId: selected.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (checked: boolean) => {
|
||||
onChange({
|
||||
...mapping,
|
||||
enabled: checked,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`bg-default border-subtle flex flex-wrap items-center gap-3 rounded-lg border p-3 ${error ? "border-red-500" : ""}`}>
|
||||
<div className="flex-1" style={{ minWidth: "200px" }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={mapping.integrationFieldName}
|
||||
onChange={handleFieldNameChange}
|
||||
disabled={isLoading}
|
||||
placeholder={t("attribute_sync_field_placeholder")}
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
|
||||
<div className="flex-1" style={{ minWidth: "200px" }}>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder={t("attribute_sync_select_attribute")}
|
||||
options={attributeOptions}
|
||||
value={selectedAttribute}
|
||||
onChange={handleAttributeChange}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Switch checked={mapping.enabled} onCheckedChange={handleEnabledChange} disabled={isLoading} />
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="trash-2"
|
||||
onClick={onRemove}
|
||||
disabled={isLoading}
|
||||
className="text-subtle hover:text-default"
|
||||
aria-label={t("remove")}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FieldMappingBuilder = ({
|
||||
value,
|
||||
onChange,
|
||||
attributeOptions,
|
||||
isLoadingAttributes,
|
||||
}: FieldMappingBuilderProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Find indices of rows with duplicate attribute mappings
|
||||
const duplicateIndices = new Set<number>();
|
||||
const seenAttributes = new Map<string, number>(); // attributeId -> first index
|
||||
value.mappings.forEach((mapping, index) => {
|
||||
if (mapping.attributeId) {
|
||||
const firstIndex = seenAttributes.get(mapping.attributeId);
|
||||
if (firstIndex !== undefined) {
|
||||
// Mark both the first occurrence and current as duplicates
|
||||
duplicateIndices.add(firstIndex);
|
||||
duplicateIndices.add(index);
|
||||
} else {
|
||||
seenAttributes.set(mapping.attributeId, index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleAddMapping = () => {
|
||||
onChange({
|
||||
...value,
|
||||
mappings: [...value.mappings, getDefaultFieldMapping()],
|
||||
});
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, newMapping: IFieldMappingWithOptionalId) => {
|
||||
const newMappings = [...value.mappings];
|
||||
newMappings[index] = newMapping;
|
||||
onChange({
|
||||
...value,
|
||||
mappings: newMappings,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
onChange({
|
||||
...value,
|
||||
mappings: value.mappings.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle rounded-2xl border p-2">
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="link" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">{t("attribute_sync_field_mappings")}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted mt-2 space-y-2 rounded-xl p-2">
|
||||
{value.mappings.length === 0 ? (
|
||||
<div className="text-subtle py-6 text-center text-sm">{t("attribute_sync_no_mappings")}</div>
|
||||
) : (
|
||||
value.mappings.map((mapping, index) => (
|
||||
<MappingRow
|
||||
key={mapping.id || `new-${index}`}
|
||||
mapping={mapping}
|
||||
onChange={(newMapping) => handleMappingChange(index, newMapping)}
|
||||
onRemove={() => handleRemoveMapping(index)}
|
||||
attributeOptions={attributeOptions}
|
||||
isLoading={isLoadingAttributes}
|
||||
error={
|
||||
duplicateIndices.has(index)
|
||||
? t("attribute_sync_duplicate_attribute_mapping")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="mt-2"
|
||||
onClick={handleAddMapping}
|
||||
disabled={isLoadingAttributes}>
|
||||
{t("attribute_sync_add_mapping")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { FormCard, FormCardBody } from "@calcom/ui/components/card";
|
||||
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
|
||||
import { SelectField, Switch } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import {
|
||||
type IntegrationAttributeSync,
|
||||
type ISyncFormData,
|
||||
RuleOperatorEnum,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import { FieldMappingBuilder } from "./FieldMappingBuilder";
|
||||
import { RuleBuilder } from "./RuleBuilder";
|
||||
|
||||
export interface IIntegrationAttributeSyncCardProps {
|
||||
sync?: IntegrationAttributeSync; // Optional for create mode
|
||||
credentialOptions: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
teamOptions: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
attributes: Attribute[];
|
||||
attributeOptions: {
|
||||
value: string;
|
||||
label: string;
|
||||
}[];
|
||||
organizationId: number;
|
||||
onSubmit: (data: ISyncFormData) => void;
|
||||
isSubmitting: boolean;
|
||||
onCancel?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const IntegrationAttributeSyncCard = (
|
||||
props: IIntegrationAttributeSyncCardProps
|
||||
) => {
|
||||
const {
|
||||
sync,
|
||||
credentialOptions,
|
||||
teamOptions,
|
||||
attributes,
|
||||
attributeOptions,
|
||||
organizationId,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onDelete,
|
||||
isSubmitting,
|
||||
} = props;
|
||||
|
||||
const { t } = useLocale();
|
||||
const isCreateMode = !sync;
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Initialize form with sync data or defaults
|
||||
const form = useForm<ISyncFormData>({
|
||||
defaultValues: sync
|
||||
? {
|
||||
id: sync.id,
|
||||
name: sync.name,
|
||||
credentialId: sync.credentialId ?? undefined,
|
||||
enabled: sync.enabled,
|
||||
organizationId: sync.organizationId,
|
||||
ruleId: sync.attributeSyncRule?.id || "",
|
||||
rule: sync.attributeSyncRule?.rule || {
|
||||
operator: RuleOperatorEnum.AND,
|
||||
conditions: [],
|
||||
},
|
||||
syncFieldMappings: Array.isArray(sync.syncFieldMappings)
|
||||
? sync.syncFieldMappings
|
||||
: [],
|
||||
}
|
||||
: {
|
||||
id: "",
|
||||
name: t("attribute_sync_new_integration_sync"),
|
||||
credentialId: undefined,
|
||||
enabled: true,
|
||||
organizationId,
|
||||
ruleId: "",
|
||||
rule: { operator: RuleOperatorEnum.AND, conditions: [] },
|
||||
syncFieldMappings: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when sync prop changes (after refetch with new IDs)
|
||||
useEffect(() => {
|
||||
if (sync) {
|
||||
form.reset({
|
||||
id: sync.id,
|
||||
name: sync.name,
|
||||
credentialId: sync.credentialId ?? undefined,
|
||||
enabled: sync.enabled,
|
||||
organizationId: sync.organizationId,
|
||||
ruleId: sync.attributeSyncRule?.id || "",
|
||||
rule: sync.attributeSyncRule?.rule || {
|
||||
operator: RuleOperatorEnum.AND,
|
||||
conditions: [],
|
||||
},
|
||||
syncFieldMappings: Array.isArray(sync.syncFieldMappings)
|
||||
? sync.syncFieldMappings
|
||||
: [],
|
||||
});
|
||||
}
|
||||
// Only need to run if the sync changes
|
||||
}, [sync?.id]);
|
||||
|
||||
const onFormSubmit = (data: ISyncFormData) => {
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!sync) return;
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!sync || !onDelete) return;
|
||||
onDelete();
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
const formName = form.watch("name");
|
||||
const isPending = isSubmitting;
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<FormCard
|
||||
label={formName}
|
||||
leftIcon="link"
|
||||
collapsible={true}
|
||||
isLabelEditable={true}
|
||||
onLabelChange={(label) => {
|
||||
form.setValue("name", label, { shouldDirty: true });
|
||||
}}
|
||||
customActions={
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
deleteField={
|
||||
!isCreateMode && onDelete
|
||||
? {
|
||||
check: () => true,
|
||||
fn: handleDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FormCardBody>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-default border-subtle rounded-2xl border p-2">
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="key" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">
|
||||
{t("attribute_sync_credential")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Controller
|
||||
name="credentialId"
|
||||
control={form.control}
|
||||
rules={{
|
||||
required: t("attribute_sync_credential_required"),
|
||||
validate: (value) => {
|
||||
if (!value || value <= 0) {
|
||||
return t("attribute_sync_credential_validation");
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<SelectField
|
||||
placeholder={t("attribute_sync_select_credential")}
|
||||
options={credentialOptions}
|
||||
value={
|
||||
credentialOptions.find(
|
||||
(opt) => Number(opt.value) === field.value
|
||||
) || null
|
||||
}
|
||||
onChange={(option) =>
|
||||
field.onChange(Number(option?.value))
|
||||
}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<p className="text-subtle mt-1 text-xs">
|
||||
{t("attribute_sync_credential_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{t("attribute_sync_user_filter_rules")}
|
||||
</label>
|
||||
<Controller
|
||||
name="rule"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<RuleBuilder
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
teamOptions={teamOptions}
|
||||
attributes={attributes ?? []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-subtle mt-1 text-xs">
|
||||
{t("attribute_sync_user_filter_rules_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-emphasis mb-2 block text-sm font-medium">
|
||||
{t("attribute_sync_field_mappings")}
|
||||
</label>
|
||||
<Controller
|
||||
name="syncFieldMappings"
|
||||
control={form.control}
|
||||
rules={{
|
||||
validate: (mappings) => {
|
||||
const seenAttributes = new Set<string>();
|
||||
for (const mapping of mappings || []) {
|
||||
if (
|
||||
mapping.attributeId &&
|
||||
seenAttributes.has(mapping.attributeId)
|
||||
) {
|
||||
return t(
|
||||
"attribute_sync_duplicate_attribute_mapping"
|
||||
);
|
||||
}
|
||||
if (mapping.attributeId)
|
||||
seenAttributes.add(mapping.attributeId);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FieldMappingBuilder
|
||||
value={{ mappings: field.value || [] }}
|
||||
onChange={(fieldMappings) =>
|
||||
field.onChange(fieldMappings.mappings)
|
||||
}
|
||||
attributeOptions={attributeOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-subtle mt-1 text-xs">
|
||||
{t("attribute_sync_field_mappings_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-subtle flex justify-end gap-2 border-t pt-4">
|
||||
{isCreateMode && onCancel && (
|
||||
<Button type="button" color="secondary" onClick={onCancel}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
disabled={!form.formState.isDirty}
|
||||
>
|
||||
{isCreateMode ? t("create") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormCardBody>
|
||||
</FormCard>
|
||||
</form>
|
||||
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("attribute_sync_delete_title")}
|
||||
confirmBtnText={t("delete")}
|
||||
onConfirm={confirmDelete}
|
||||
>
|
||||
{t("attribute_sync_delete_confirmation")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationAttributeSyncCard;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { ISyncFormData } from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import IntegrationAttributeSyncCard from "./IntegrationAttributeSyncCard";
|
||||
import type { IIntegrationAttributeSyncCardProps } from "./IntegrationAttributeSyncCard";
|
||||
|
||||
type INewIntegrationAttributeSyncCardProps = Pick<
|
||||
IIntegrationAttributeSyncCardProps,
|
||||
"credentialOptions" | "teamOptions" | "attributes" | "organizationId" | "onCancel" | "attributeOptions"
|
||||
>;
|
||||
|
||||
const NewIntegrationAttributeSyncCard = (props: INewIntegrationAttributeSyncCardProps) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const createMutation = trpc.viewer.attributeSync.createAttributeSync.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
|
||||
showToast(t("attribute_sync_created_successfully"), "success");
|
||||
props.onCancel?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: ISyncFormData) => {
|
||||
if (!data.credentialId) {
|
||||
showToast(t("attribute_sync_credential_required"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate({
|
||||
name: data.name,
|
||||
credentialId: data.credentialId,
|
||||
rule: data.rule,
|
||||
syncFieldMappings: data.syncFieldMappings,
|
||||
enabled: data.enabled,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<IntegrationAttributeSyncCard {...props} onSubmit={onSubmit} isSubmitting={createMutation.isPending} />
|
||||
);
|
||||
};
|
||||
|
||||
export default NewIntegrationAttributeSyncCard;
|
||||
@@ -0,0 +1,495 @@
|
||||
import type { MultiValue, SingleValue } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Input, Select } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import {
|
||||
formatConditionValue,
|
||||
generateConditionId,
|
||||
getConditionTypeOptions,
|
||||
getDefaultAttributeCondition,
|
||||
getDefaultTeamCondition,
|
||||
getOperatorOptionsForAttributeType,
|
||||
getParentOperatorOptions,
|
||||
getTeamOperatorOptions,
|
||||
isArrayOperator,
|
||||
isTeamCondition,
|
||||
type TAttributeSyncRuleConditionWithId,
|
||||
} from "../lib/ruleHelpers";
|
||||
import {
|
||||
ConditionIdentifierEnum,
|
||||
type ConditionOperatorEnum,
|
||||
type IAttributeCondition,
|
||||
type IAttributeSyncRule,
|
||||
type ITeamCondition,
|
||||
type RuleOperatorEnum,
|
||||
type TAttributeSyncRuleCondition,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
|
||||
const ensureConditionHasId = (condition: TAttributeSyncRuleCondition): TAttributeSyncRuleConditionWithId => {
|
||||
if ("_id" in condition && condition._id) {
|
||||
return condition as TAttributeSyncRuleConditionWithId;
|
||||
}
|
||||
return { ...condition, _id: generateConditionId() } as TAttributeSyncRuleConditionWithId;
|
||||
};
|
||||
|
||||
interface RuleBuilderProps {
|
||||
value: IAttributeSyncRule;
|
||||
onChange: (value: IAttributeSyncRule) => void;
|
||||
teamOptions: { value: string; label: string }[];
|
||||
attributes: Attribute[];
|
||||
isLoadingTeams?: boolean;
|
||||
isLoadingAttributes?: boolean;
|
||||
}
|
||||
|
||||
interface ConditionComponentProps {
|
||||
condition: TAttributeSyncRuleCondition;
|
||||
onChange: (condition: TAttributeSyncRuleCondition) => void;
|
||||
onRemove: () => void;
|
||||
teamOptions: { value: string; label: string }[];
|
||||
attributes: Attribute[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ConditionComponent = ({
|
||||
condition,
|
||||
onChange,
|
||||
onRemove,
|
||||
teamOptions,
|
||||
attributes,
|
||||
isLoading,
|
||||
}: ConditionComponentProps) => {
|
||||
const { t } = useLocale();
|
||||
const conditionTypeOptions = getConditionTypeOptions(t);
|
||||
const conditionTypeOption = conditionTypeOptions.find((opt) => opt.value === condition.identifier);
|
||||
|
||||
const handleTypeChange = (newType: { value: ConditionIdentifierEnum; label: string } | null) => {
|
||||
if (!newType) return;
|
||||
|
||||
if (newType.value === ConditionIdentifierEnum.TEAM_ID) {
|
||||
onChange(getDefaultTeamCondition());
|
||||
} else {
|
||||
onChange(getDefaultAttributeCondition());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
||||
{/* Condition type selector */}
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-32"
|
||||
value={conditionTypeOption}
|
||||
onChange={handleTypeChange}
|
||||
options={conditionTypeOptions}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Render team or attribute specific fields */}
|
||||
{isTeamCondition(condition) ? (
|
||||
<TeamConditionFields
|
||||
condition={condition}
|
||||
onChange={onChange}
|
||||
teamOptions={teamOptions}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<AttributeConditionFields
|
||||
condition={condition}
|
||||
onChange={onChange}
|
||||
attributes={attributes}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="trash-2"
|
||||
onClick={onRemove}
|
||||
disabled={isLoading}
|
||||
className="text-subtle hover:text-default"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TeamConditionFieldsProps {
|
||||
condition: ITeamCondition;
|
||||
onChange: (condition: TAttributeSyncRuleCondition) => void;
|
||||
teamOptions: { value: string; label: string }[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const TeamConditionFields = ({ condition, onChange, teamOptions, isLoading }: TeamConditionFieldsProps) => {
|
||||
const { t } = useLocale();
|
||||
const teamOperatorOptions = getTeamOperatorOptions(t);
|
||||
const operatorOption = teamOperatorOptions.find((opt) => opt.value === condition.operator);
|
||||
const isMulti = isArrayOperator(condition.operator);
|
||||
|
||||
const selectedTeams = isMulti
|
||||
? teamOptions.filter((opt) => condition.value.includes(Number(opt.value)))
|
||||
: teamOptions.find((opt) => Number(opt.value) === condition.value[0]);
|
||||
|
||||
const handleOperatorChange = (newOperator: { value: ConditionOperatorEnum; label: string } | null) => {
|
||||
if (!newOperator) return;
|
||||
|
||||
const formattedValue = formatConditionValue(newOperator.value, condition.value) as number[];
|
||||
onChange({
|
||||
...condition,
|
||||
operator: newOperator.value,
|
||||
value: formattedValue,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTeamsChange = (
|
||||
selected: MultiValue<{ value: string; label: string }> | SingleValue<{ value: string; label: string }>
|
||||
) => {
|
||||
if (!selected) {
|
||||
onChange({ ...condition, value: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(selected)) {
|
||||
onChange({
|
||||
...condition,
|
||||
value: selected.map((s) => Number(s.value)),
|
||||
});
|
||||
} else {
|
||||
const singleValue = selected as { value: string; label: string };
|
||||
onChange({
|
||||
...condition,
|
||||
value: [Number(singleValue.value)],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-default text-sm">{t("team").toLowerCase()}</span>
|
||||
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-40"
|
||||
value={operatorOption}
|
||||
onChange={handleOperatorChange}
|
||||
options={teamOperatorOptions}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex-1" style={{ minWidth: "200px" }}>
|
||||
<Select
|
||||
size="sm"
|
||||
isMulti={isMulti}
|
||||
value={selectedTeams}
|
||||
onChange={handleTeamsChange}
|
||||
options={teamOptions}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
placeholder={t("attribute_sync_select_teams")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttributeConditionFieldsProps {
|
||||
condition: IAttributeCondition;
|
||||
onChange: (condition: TAttributeSyncRuleCondition) => void;
|
||||
attributes: Attribute[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const AttributeConditionFields = ({
|
||||
condition,
|
||||
onChange,
|
||||
attributes,
|
||||
isLoading,
|
||||
}: AttributeConditionFieldsProps) => {
|
||||
const { t } = useLocale();
|
||||
const selectedAttribute = attributes.find((attr) => attr.id === condition.attributeId);
|
||||
|
||||
const attributeOptions = attributes.map((attr) => ({
|
||||
value: attr.id,
|
||||
label: attr.name,
|
||||
}));
|
||||
|
||||
const operatorOptions = selectedAttribute
|
||||
? getOperatorOptionsForAttributeType(selectedAttribute.type, t)
|
||||
: [];
|
||||
|
||||
const operatorOption = operatorOptions.find((opt) => opt.value === condition.operator);
|
||||
|
||||
const handleAttributeChange = (selected: { value: string; label: string } | null) => {
|
||||
if (!selected) return;
|
||||
|
||||
const newAttribute = attributes.find((attr) => attr.id === selected.value);
|
||||
if (!newAttribute) return;
|
||||
|
||||
// Reset operator and value when attribute changes
|
||||
const defaultOperator = getOperatorOptionsForAttributeType(newAttribute.type, t)[0];
|
||||
|
||||
onChange({
|
||||
...condition,
|
||||
attributeId: selected.value,
|
||||
operator: defaultOperator.value,
|
||||
value: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleOperatorChange = (newOperator: { value: ConditionOperatorEnum; label: string } | null) => {
|
||||
if (!newOperator) return;
|
||||
|
||||
const formattedValue = formatConditionValue(newOperator.value, condition.value) as string[];
|
||||
onChange({
|
||||
...condition,
|
||||
operator: newOperator.value,
|
||||
value: formattedValue,
|
||||
});
|
||||
};
|
||||
|
||||
const handleValueChange = (
|
||||
selected: MultiValue<{ value: string; label: string }> | SingleValue<{ value: string; label: string }>
|
||||
) => {
|
||||
if (!selected) {
|
||||
onChange({ ...condition, value: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(selected)) {
|
||||
onChange({
|
||||
...condition,
|
||||
value: selected.map((s) => s.value),
|
||||
});
|
||||
} else {
|
||||
const singleValue = selected as { value: string; label: string };
|
||||
onChange({
|
||||
...condition,
|
||||
value: [singleValue.value],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...condition,
|
||||
value: [e.target.value],
|
||||
});
|
||||
};
|
||||
|
||||
const handleNumberValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...condition,
|
||||
value: [e.target.value],
|
||||
});
|
||||
};
|
||||
|
||||
// Render value input based on attribute type
|
||||
const renderValueInput = () => {
|
||||
if (!selectedAttribute) {
|
||||
return (
|
||||
<div className="text-subtle flex-1 text-sm" style={{ minWidth: "200px" }}>
|
||||
{t("attribute_sync_select_attribute_first")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMulti = isArrayOperator(condition.operator);
|
||||
|
||||
switch (selectedAttribute.type) {
|
||||
case "SINGLE_SELECT":
|
||||
case "MULTI_SELECT": {
|
||||
const valueOptions = selectedAttribute.options.map((opt: { id: string; value: string }) => ({
|
||||
value: opt.id,
|
||||
label: opt.value,
|
||||
}));
|
||||
|
||||
const selectedValues = isMulti
|
||||
? valueOptions.filter((opt: { value: string; label: string }) => condition.value.includes(opt.value))
|
||||
: valueOptions.find((opt: { value: string; label: string }) => condition.value[0] === opt.value);
|
||||
|
||||
return (
|
||||
<div className="flex-1" style={{ minWidth: "200px" }}>
|
||||
<Select
|
||||
size="sm"
|
||||
isMulti={isMulti}
|
||||
value={selectedValues}
|
||||
onChange={handleValueChange}
|
||||
options={valueOptions}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
placeholder={t("attribute_sync_select_values")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "TEXT":
|
||||
return (
|
||||
<div className="flex-1" style={{ minWidth: "200px" }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={condition.value[0] || ""}
|
||||
onChange={handleTextValueChange}
|
||||
disabled={isLoading}
|
||||
placeholder={t("attribute_sync_enter_text_value")}
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "NUMBER":
|
||||
return (
|
||||
<div className="flex-1" style={{ minWidth: "200px" }}>
|
||||
<Input
|
||||
type="number"
|
||||
value={condition.value[0] || ""}
|
||||
onChange={handleNumberValueChange}
|
||||
disabled={isLoading}
|
||||
placeholder={t("attribute_sync_enter_number_value")}
|
||||
className="h-7 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-default text-sm">{t("attribute").toLowerCase()}</span>
|
||||
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-48"
|
||||
value={attributeOptions.find((opt) => opt.value === condition.attributeId)}
|
||||
onChange={handleAttributeChange}
|
||||
options={attributeOptions}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
placeholder={t("attribute_sync_select_attribute")}
|
||||
/>
|
||||
|
||||
{selectedAttribute && (
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-40"
|
||||
value={operatorOption}
|
||||
onChange={handleOperatorChange}
|
||||
options={operatorOptions}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
{renderValueInput()}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const RuleBuilder = ({
|
||||
value,
|
||||
onChange,
|
||||
teamOptions,
|
||||
attributes,
|
||||
isLoadingTeams,
|
||||
isLoadingAttributes,
|
||||
}: RuleBuilderProps) => {
|
||||
const { t } = useLocale();
|
||||
const parentOperatorOptions = getParentOperatorOptions(t);
|
||||
const parentOperatorOption = parentOperatorOptions.find((opt) => opt.value === value.operator);
|
||||
const isLoading = isLoadingTeams || isLoadingAttributes;
|
||||
|
||||
const handleOperatorChange = (newOperator: { value: RuleOperatorEnum; label: string } | null) => {
|
||||
if (!newOperator) return;
|
||||
onChange({
|
||||
...value,
|
||||
operator: newOperator.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
onChange({
|
||||
...value,
|
||||
conditions: [...value.conditions, getDefaultTeamCondition()],
|
||||
});
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, newCondition: TAttributeSyncRuleCondition) => {
|
||||
const newConditions = [...value.conditions];
|
||||
newConditions[index] = newCondition;
|
||||
onChange({
|
||||
...value,
|
||||
conditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
onChange({
|
||||
...value,
|
||||
conditions: value.conditions.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle rounded-2xl border p-2">
|
||||
<div className="ml-2 flex items-center gap-0.5">
|
||||
<div className="border-subtle rounded-lg border p-1">
|
||||
<Icon name="filter" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-emphasis ml-2 text-sm font-medium">{t("attribute_sync_user_filter_rules")}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 px-2">
|
||||
<span className="text-default text-sm">{t("attribute_sync_sync_users_where")}</span>
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-28"
|
||||
value={parentOperatorOption}
|
||||
onChange={handleOperatorChange}
|
||||
options={parentOperatorOptions}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
<span className="text-default text-sm">{t("attribute_sync_conditions_match")}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted mt-2 space-y-2 rounded-xl p-2">
|
||||
{value.conditions.length === 0 ? (
|
||||
<div className="text-subtle py-6 text-center text-sm">
|
||||
{t("attribute_sync_no_conditions")}
|
||||
</div>
|
||||
) : (
|
||||
value.conditions.map((condition, index) => {
|
||||
const conditionWithId = ensureConditionHasId(condition);
|
||||
return (
|
||||
<ConditionComponent
|
||||
key={conditionWithId._id}
|
||||
condition={conditionWithId}
|
||||
onChange={(newCondition) => handleConditionChange(index, newCondition)}
|
||||
onRemove={() => handleRemoveCondition(index)}
|
||||
teamOptions={teamOptions}
|
||||
attributes={attributes}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="plus"
|
||||
className="mt-2"
|
||||
onClick={handleAddCondition}
|
||||
disabled={isLoading}>
|
||||
{t("attribute_sync_add_condition")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Slugs of apps that support attribute syncing */
|
||||
export const enabledAppSlugs = ["salesforce"];
|
||||
+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 { PrismaIntegrationAttributeSyncRepository } from "../repositories/PrismaIntegrationAttributeSyncRepository";
|
||||
import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens";
|
||||
|
||||
export const integrationAttributeSyncRepositoryModule = createModule();
|
||||
const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY;
|
||||
const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY_MODULE;
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: integrationAttributeSyncRepositoryModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: PrismaIntegrationAttributeSyncRepository,
|
||||
dep: prismaModuleLoader,
|
||||
});
|
||||
|
||||
export const moduleLoader: ModuleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
export type { IIntegrationAttributeSyncRepository } from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { createContainer } from "@calcom/features/di/di";
|
||||
|
||||
import {
|
||||
type IntegrationAttributeSyncService,
|
||||
moduleLoader as integrationAttributeSyncServiceModule,
|
||||
} from "./IntegrationAttributeSyncService.module";
|
||||
|
||||
const integrationAttributeSyncServiceContainer = createContainer();
|
||||
|
||||
export function getIntegrationAttributeSyncService(): IntegrationAttributeSyncService {
|
||||
integrationAttributeSyncServiceModule.loadModule(integrationAttributeSyncServiceContainer);
|
||||
|
||||
return integrationAttributeSyncServiceContainer.get<IntegrationAttributeSyncService>(
|
||||
integrationAttributeSyncServiceModule.token
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
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 { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens";
|
||||
import { moduleLoader as integrationAttributeSyncRepositoryModuleLoader } from "./IntegrationAttributeSyncRepository.module";
|
||||
|
||||
export const integrationAttributeSyncServiceModule = createModule();
|
||||
const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_SERVICE;
|
||||
const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_SERVICE_MODULE;
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: integrationAttributeSyncServiceModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: IntegrationAttributeSyncService,
|
||||
depsMap: {
|
||||
credentialRepository: credentialRepositoryModuleLoader,
|
||||
integrationAttributeSyncRepository: integrationAttributeSyncRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader: ModuleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
export type { IntegrationAttributeSyncService };
|
||||
@@ -0,0 +1,6 @@
|
||||
export const INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS = {
|
||||
INTEGRATION_ATTRIBUTE_SYNC_SERVICE: Symbol("IntegrationAttributeSyncService"),
|
||||
INTEGRATION_ATTRIBUTE_SYNC_SERVICE_MODULE: Symbol("IntegrationAttributeSyncServiceModule"),
|
||||
INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY: Symbol("IntegrationAttributeSyncRepository"),
|
||||
INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY_MODULE: Symbol("IntegrationAttributeSyncRepositoryModule"),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { IFieldMappingWithOptionalId } from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
|
||||
export const getDefaultFieldMapping = (): IFieldMappingWithOptionalId => ({
|
||||
integrationFieldName: "",
|
||||
attributeId: "",
|
||||
enabled: true,
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { AttributeType } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
ConditionIdentifierEnum,
|
||||
ConditionOperatorEnum,
|
||||
type IAttributeCondition,
|
||||
type ITeamCondition,
|
||||
RuleOperatorEnum,
|
||||
type TAttributeSyncRuleCondition,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
|
||||
type TFunction = (key: string) => string;
|
||||
|
||||
// Unique ID generator for React keys
|
||||
export const generateConditionId = (): string => {
|
||||
return crypto.randomUUID();
|
||||
};
|
||||
|
||||
// Extended condition types with client-side ID for React keys
|
||||
export interface ITeamConditionWithId extends ITeamCondition {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export interface IAttributeConditionWithId extends IAttributeCondition {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export type TAttributeSyncRuleConditionWithId = ITeamConditionWithId | IAttributeConditionWithId;
|
||||
|
||||
// Factory functions
|
||||
export const getDefaultTeamCondition = (): ITeamConditionWithId => ({
|
||||
_id: generateConditionId(),
|
||||
identifier: ConditionIdentifierEnum.TEAM_ID,
|
||||
operator: ConditionOperatorEnum.IN,
|
||||
value: [],
|
||||
});
|
||||
|
||||
export const getDefaultAttributeCondition = (): IAttributeConditionWithId => ({
|
||||
_id: generateConditionId(),
|
||||
identifier: ConditionIdentifierEnum.ATTRIBUTE_ID,
|
||||
attributeId: "",
|
||||
operator: ConditionOperatorEnum.EQUALS,
|
||||
value: [],
|
||||
});
|
||||
|
||||
// Type guards
|
||||
export const isTeamCondition = (condition: TAttributeSyncRuleCondition): condition is ITeamCondition => {
|
||||
return condition.identifier === ConditionIdentifierEnum.TEAM_ID;
|
||||
};
|
||||
|
||||
export const isAttributeCondition = (
|
||||
condition: TAttributeSyncRuleCondition
|
||||
): condition is IAttributeCondition => {
|
||||
return condition.identifier === ConditionIdentifierEnum.ATTRIBUTE_ID;
|
||||
};
|
||||
|
||||
export const isArrayOperator = (operator: ConditionOperatorEnum): boolean => {
|
||||
return operator === ConditionOperatorEnum.IN || operator === ConditionOperatorEnum.NOT_IN;
|
||||
};
|
||||
|
||||
export const formatConditionValue = (
|
||||
operator: ConditionOperatorEnum,
|
||||
value: number[] | string[]
|
||||
): number[] | string[] => {
|
||||
if (isArrayOperator(operator)) {
|
||||
return Array.isArray(value) ? value : value ? [value] : [];
|
||||
} else {
|
||||
return Array.isArray(value) ? value.slice(0, 1) : value ? [value] : [];
|
||||
}
|
||||
};
|
||||
|
||||
// Operator options for teams
|
||||
export const getTeamOperatorOptions = (t: TFunction) => [
|
||||
{ value: ConditionOperatorEnum.IN, label: t("attribute_sync_operator_is_any_of") },
|
||||
{ value: ConditionOperatorEnum.NOT_IN, label: t("attribute_sync_operator_is_not_any_of") },
|
||||
];
|
||||
|
||||
// Operator options based on attribute type
|
||||
export const getOperatorOptionsForAttributeType = (type: AttributeType, t: TFunction) => {
|
||||
switch (type) {
|
||||
case "SINGLE_SELECT":
|
||||
return [
|
||||
{ value: ConditionOperatorEnum.EQUALS, label: t("attribute_sync_operator_is") },
|
||||
{ value: ConditionOperatorEnum.NOT_EQUALS, label: t("attribute_sync_operator_is_not") },
|
||||
];
|
||||
case "MULTI_SELECT":
|
||||
return [
|
||||
{ value: ConditionOperatorEnum.IN, label: t("attribute_sync_operator_includes_any_of") },
|
||||
{ value: ConditionOperatorEnum.NOT_IN, label: t("attribute_sync_operator_does_not_include") },
|
||||
];
|
||||
case "TEXT":
|
||||
case "NUMBER":
|
||||
return [
|
||||
{ value: ConditionOperatorEnum.EQUALS, label: t("attribute_sync_operator_equals") },
|
||||
{ value: ConditionOperatorEnum.NOT_EQUALS, label: t("attribute_sync_operator_not_equals") },
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export const getParentOperatorOptions = (t: TFunction) => [
|
||||
{ value: RuleOperatorEnum.AND, label: t("all") },
|
||||
{ value: RuleOperatorEnum.OR, label: t("any") },
|
||||
];
|
||||
|
||||
export const getConditionTypeOptions = (t: TFunction) => [
|
||||
{ value: ConditionIdentifierEnum.TEAM_ID, label: t("team") },
|
||||
{ value: ConditionIdentifierEnum.ATTRIBUTE_ID, label: t("attribute") },
|
||||
];
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
AttributeSyncRule as PrismaAttributeSyncRule,
|
||||
IntegrationAttributeSync as PrismaIntegrationAttributeSync,
|
||||
AttributeSyncFieldMapping as PrismaAttributeSyncFieldMapping,
|
||||
} from "@calcom/prisma/client";
|
||||
|
||||
import type {
|
||||
AttributeSyncRule,
|
||||
IntegrationAttributeSync,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import { AttributeSyncIntegrations } from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import { attributeSyncRuleSchema } from "../schemas/zod";
|
||||
|
||||
type PrismaIntegrationAttributeSyncWithRelations = Pick<
|
||||
PrismaIntegrationAttributeSync,
|
||||
"id" | "name" | "organizationId" | "integration" | "credentialId" | "enabled"
|
||||
> & {
|
||||
attributeSyncRule: PrismaAttributeSyncRule | null;
|
||||
syncFieldMappings: Pick<
|
||||
PrismaAttributeSyncFieldMapping,
|
||||
"id" | "integrationFieldName" | "attributeId" | "enabled"
|
||||
>[];
|
||||
};
|
||||
|
||||
export class IntegrationAttributeSyncOutputMapper {
|
||||
static attributeSyncRuleToDomain(prisma: PrismaAttributeSyncRule): AttributeSyncRule {
|
||||
const parsedRules = attributeSyncRuleSchema.parse(prisma.rule);
|
||||
|
||||
return {
|
||||
...prisma,
|
||||
rule: parsedRules,
|
||||
};
|
||||
}
|
||||
|
||||
static toDomain(prisma: PrismaIntegrationAttributeSyncWithRelations): IntegrationAttributeSync {
|
||||
return {
|
||||
id: prisma.id,
|
||||
name: prisma.name,
|
||||
organizationId: prisma.organizationId,
|
||||
integration: prisma.integration as AttributeSyncIntegrations,
|
||||
credentialId: prisma.credentialId ?? undefined,
|
||||
enabled: prisma.enabled,
|
||||
attributeSyncRule: prisma.attributeSyncRule
|
||||
? IntegrationAttributeSyncOutputMapper.attributeSyncRuleToDomain(prisma.attributeSyncRule)
|
||||
: null,
|
||||
syncFieldMappings: prisma.syncFieldMappings,
|
||||
};
|
||||
}
|
||||
|
||||
static toDomainList(prismaList: PrismaIntegrationAttributeSyncWithRelations[]): IntegrationAttributeSync[] {
|
||||
return prismaList.map(IntegrationAttributeSyncOutputMapper.toDomain);
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
export enum AttributeSyncIntegrations {
|
||||
SALESFORCE = "salesforce",
|
||||
}
|
||||
|
||||
export enum RuleOperatorEnum {
|
||||
AND = "AND",
|
||||
OR = "OR",
|
||||
}
|
||||
|
||||
export enum ConditionIdentifierEnum {
|
||||
TEAM_ID = "teamId",
|
||||
ATTRIBUTE_ID = "attributeId",
|
||||
}
|
||||
|
||||
export enum ConditionOperatorEnum {
|
||||
EQUALS = "equals",
|
||||
NOT_EQUALS = "notEquals",
|
||||
IN = "in",
|
||||
NOT_IN = "notIn",
|
||||
}
|
||||
|
||||
// Condition types
|
||||
export interface ITeamCondition {
|
||||
identifier: ConditionIdentifierEnum.TEAM_ID;
|
||||
operator: ConditionOperatorEnum;
|
||||
value: number[];
|
||||
}
|
||||
|
||||
export interface IAttributeCondition {
|
||||
identifier: ConditionIdentifierEnum.ATTRIBUTE_ID;
|
||||
operator: ConditionOperatorEnum;
|
||||
attributeId: string;
|
||||
value: string[];
|
||||
}
|
||||
|
||||
export type TAttributeSyncRuleCondition = ITeamCondition | IAttributeCondition;
|
||||
|
||||
export interface IAttributeSyncRule {
|
||||
operator: RuleOperatorEnum;
|
||||
conditions: TAttributeSyncRuleCondition[];
|
||||
}
|
||||
|
||||
// Field mapping types
|
||||
export interface INewFieldMapping {
|
||||
integrationFieldName: string;
|
||||
attributeId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IFieldMapping extends INewFieldMapping {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IFieldMappingWithOptionalId extends INewFieldMapping {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface IFieldMappingFormState {
|
||||
mappings: IFieldMappingWithOptionalId[];
|
||||
}
|
||||
|
||||
export interface ISyncFormData {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialId?: number;
|
||||
enabled: boolean;
|
||||
organizationId: number;
|
||||
ruleId: string;
|
||||
rule: IAttributeSyncRule;
|
||||
syncFieldMappings: (IFieldMapping | INewFieldMapping)[];
|
||||
}
|
||||
export interface IntegrationAttributeSync {
|
||||
id: string;
|
||||
organizationId: number;
|
||||
name: string;
|
||||
integration: AttributeSyncIntegrations;
|
||||
credentialId?: number;
|
||||
enabled: boolean;
|
||||
attributeSyncRule: AttributeSyncRule | null;
|
||||
syncFieldMappings: AttributeSyncFieldMapping[];
|
||||
}
|
||||
|
||||
export interface AttributeSyncRule {
|
||||
id: string;
|
||||
rule: IAttributeSyncRule;
|
||||
}
|
||||
|
||||
export interface AttributeSyncFieldMapping {
|
||||
id: string;
|
||||
integrationFieldName: string;
|
||||
attributeId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IIntegrationAttributeSyncCreateParams {
|
||||
name: string;
|
||||
organizationId: number;
|
||||
integration: AttributeSyncIntegrations;
|
||||
credentialId: number;
|
||||
enabled: boolean;
|
||||
rule: IAttributeSyncRule;
|
||||
syncFieldMappings: Omit<AttributeSyncFieldMapping, "id">[];
|
||||
}
|
||||
|
||||
// TRPC schema types
|
||||
export interface ICreateAttributeSyncInput {
|
||||
name: string;
|
||||
credentialId: number;
|
||||
rule: IAttributeSyncRule;
|
||||
syncFieldMappings: INewFieldMapping[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IUpdateAttributeSyncInput {
|
||||
id: string;
|
||||
name: string;
|
||||
credentialId?: number;
|
||||
enabled: boolean;
|
||||
organizationId: number;
|
||||
ruleId: string;
|
||||
rule: IAttributeSyncRule;
|
||||
syncFieldMappings: IFieldMappingWithOptionalId[];
|
||||
}
|
||||
|
||||
export interface IIntegrationAttributeSyncRepository {
|
||||
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>;
|
||||
deleteById(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IIntegrationAttributeSyncUpdateParams {
|
||||
integrationAttributeSync: Omit<
|
||||
IntegrationAttributeSync,
|
||||
"attributeSyncRule" | "syncFieldMappings" | "integration"
|
||||
>;
|
||||
attributeSyncRule: AttributeSyncRule;
|
||||
fieldMappingsToCreate: Omit<AttributeSyncFieldMapping, "id">[];
|
||||
fieldMappingsToUpdate: AttributeSyncFieldMapping[];
|
||||
fieldMappingsToDelete: string[];
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { IntegrationAttributeSyncOutputMapper } from "../mappers/IntegrationAttributeSyncOutputMapper";
|
||||
import type {
|
||||
IIntegrationAttributeSyncCreateParams,
|
||||
IIntegrationAttributeSyncRepository,
|
||||
IIntegrationAttributeSyncUpdateParams,
|
||||
} from "./IIntegrationAttributeSyncRepository";
|
||||
|
||||
export class PrismaIntegrationAttributeSyncRepository implements IIntegrationAttributeSyncRepository {
|
||||
constructor(private readonly prismaClient: PrismaClient) {}
|
||||
|
||||
async getByOrganizationId(organizationId: number) {
|
||||
const integrationAttributeSyncQuery = await this.prismaClient.integrationAttributeSync.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
integration: true,
|
||||
credentialId: true,
|
||||
enabled: true,
|
||||
attributeSyncRule: true,
|
||||
syncFieldMappings: true,
|
||||
},
|
||||
});
|
||||
return IntegrationAttributeSyncOutputMapper.toDomainList(integrationAttributeSyncQuery);
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const integrationAttributeSyncQuery = await this.prismaClient.integrationAttributeSync.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
integration: true,
|
||||
credentialId: true,
|
||||
enabled: true,
|
||||
attributeSyncRule: true,
|
||||
syncFieldMappings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integrationAttributeSyncQuery) return null;
|
||||
|
||||
return IntegrationAttributeSyncOutputMapper.toDomain(integrationAttributeSyncQuery);
|
||||
}
|
||||
|
||||
async getSyncFieldMappings(integrationAttributeSyncId: string) {
|
||||
const result = await this.prismaClient.integrationAttributeSync.findUnique({
|
||||
where: {
|
||||
id: integrationAttributeSyncId,
|
||||
},
|
||||
select: {
|
||||
syncFieldMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
integrationFieldName: true,
|
||||
attributeId: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result?.syncFieldMappings || [];
|
||||
}
|
||||
|
||||
async getMappedAttributeIdsByOrganization(
|
||||
organizationId: number,
|
||||
excludeSyncId?: string
|
||||
): Promise<string[]> {
|
||||
const result = await this.prismaClient.attributeSyncFieldMapping.findMany({
|
||||
where: {
|
||||
integrationAttributeSync: {
|
||||
organizationId,
|
||||
...(excludeSyncId && { id: { not: excludeSyncId } }),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeId: true,
|
||||
},
|
||||
distinct: ["attributeId"],
|
||||
});
|
||||
|
||||
return result.map((r) => r.attributeId);
|
||||
}
|
||||
|
||||
async getAttributeIdsByOrganization(organizationId: number, attributeIds: string[]): Promise<string[]> {
|
||||
const result = await this.prismaClient.attribute.findMany({
|
||||
where: {
|
||||
id: { in: attributeIds },
|
||||
teamId: organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return result.map((r) => r.id);
|
||||
}
|
||||
|
||||
async create(params: IIntegrationAttributeSyncCreateParams) {
|
||||
const { name, organizationId, integration, credentialId, enabled, rule, syncFieldMappings } = params;
|
||||
|
||||
const created = await this.prismaClient.integrationAttributeSync.create({
|
||||
data: {
|
||||
name,
|
||||
organizationId,
|
||||
integration,
|
||||
credentialId,
|
||||
enabled,
|
||||
attributeSyncRule: {
|
||||
create: {
|
||||
rule: rule as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
},
|
||||
syncFieldMappings: {
|
||||
create: syncFieldMappings.map((mapping) => ({
|
||||
integrationFieldName: mapping.integrationFieldName,
|
||||
attributeId: mapping.attributeId,
|
||||
enabled: mapping.enabled,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
attributeSyncRule: true,
|
||||
syncFieldMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
integrationFieldName: true,
|
||||
attributeId: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return IntegrationAttributeSyncOutputMapper.toDomain(created);
|
||||
}
|
||||
|
||||
updateTransactionWithRuleAndMappings(params: IIntegrationAttributeSyncUpdateParams) {
|
||||
const {
|
||||
integrationAttributeSync,
|
||||
attributeSyncRule,
|
||||
fieldMappingsToCreate,
|
||||
fieldMappingsToUpdate,
|
||||
fieldMappingsToDelete,
|
||||
} = params;
|
||||
|
||||
const { id: syncId, ...syncData } = integrationAttributeSync;
|
||||
|
||||
return this.prismaClient
|
||||
.$transaction([
|
||||
this.prismaClient.integrationAttributeSync.update({
|
||||
where: {
|
||||
id: syncId,
|
||||
},
|
||||
data: syncData,
|
||||
}),
|
||||
this.prismaClient.attributeSyncRule.update({
|
||||
where: {
|
||||
id: attributeSyncRule.id,
|
||||
},
|
||||
data: {
|
||||
rule: attributeSyncRule.rule as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
}),
|
||||
...fieldMappingsToUpdate.map(({ id, ...mappingData }) =>
|
||||
this.prismaClient.attributeSyncFieldMapping.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: mappingData,
|
||||
})
|
||||
),
|
||||
...(fieldMappingsToCreate.length > 0
|
||||
? [
|
||||
this.prismaClient.attributeSyncFieldMapping.createMany({
|
||||
data: fieldMappingsToCreate.map((mapping) => ({
|
||||
...mapping,
|
||||
integrationAttributeSyncId: syncId,
|
||||
})),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(fieldMappingsToDelete.length > 0
|
||||
? [
|
||||
this.prismaClient.attributeSyncFieldMapping.deleteMany({
|
||||
where: { id: { in: fieldMappingsToDelete } },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
])
|
||||
.then(() => {});
|
||||
}
|
||||
|
||||
deleteById(id: string) {
|
||||
return this.prismaClient.integrationAttributeSync
|
||||
.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
.then(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
attributeSyncRuleConditionSchema,
|
||||
attributeSyncRuleSchema,
|
||||
syncFormDataSchema,
|
||||
} from "./zod";
|
||||
|
||||
describe("attributeSyncRuleConditionSchema", () => {
|
||||
describe("team conditions", () => {
|
||||
it("should validate a valid team condition with equals operator", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "equals",
|
||||
value: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(condition);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate a valid team condition with notEquals operator", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "notEquals",
|
||||
value: [5],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate a valid team condition with in operator", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "in",
|
||||
value: [1, 2],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate a valid team condition with notIn operator", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "notIn",
|
||||
value: [3, 4],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject team condition with string values", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "equals",
|
||||
value: ["team-1", "team-2"],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject team condition with invalid operator", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "contains",
|
||||
value: [1],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attribute conditions", () => {
|
||||
it("should validate a valid attribute condition with equals operator", () => {
|
||||
const condition = {
|
||||
identifier: "attributeId",
|
||||
attributeId: "attr-123",
|
||||
operator: "equals",
|
||||
value: ["option-1", "option-2"],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(condition);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate a valid attribute condition with in operator", () => {
|
||||
const condition = {
|
||||
identifier: "attributeId",
|
||||
attributeId: "attr-456",
|
||||
operator: "in",
|
||||
value: ["opt-a", "opt-b", "opt-c"],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject attribute condition without attributeId", () => {
|
||||
const condition = {
|
||||
identifier: "attributeId",
|
||||
operator: "equals",
|
||||
value: ["option-1"],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject attribute condition with number values", () => {
|
||||
const condition = {
|
||||
identifier: "attributeId",
|
||||
attributeId: "attr-123",
|
||||
operator: "equals",
|
||||
value: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid conditions", () => {
|
||||
it("should reject condition with invalid identifier", () => {
|
||||
const condition = {
|
||||
identifier: "invalidId",
|
||||
operator: "equals",
|
||||
value: [1],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject condition without value", () => {
|
||||
const condition = {
|
||||
identifier: "teamId",
|
||||
operator: "equals",
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleConditionSchema.safeParse(condition);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("attributeSyncRuleSchema", () => {
|
||||
it("should validate a rule with AND operator and team conditions", () => {
|
||||
const rule = {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
{
|
||||
identifier: "teamId",
|
||||
operator: "equals",
|
||||
value: [1, 2],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(rule);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate a rule with OR operator and attribute conditions", () => {
|
||||
const rule = {
|
||||
operator: "OR",
|
||||
conditions: [
|
||||
{
|
||||
identifier: "attributeId",
|
||||
attributeId: "attr-1",
|
||||
operator: "in",
|
||||
value: ["opt-1"],
|
||||
},
|
||||
{
|
||||
identifier: "attributeId",
|
||||
attributeId: "attr-2",
|
||||
operator: "notIn",
|
||||
value: ["opt-2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate a rule with mixed conditions", () => {
|
||||
const rule = {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
{
|
||||
identifier: "teamId",
|
||||
operator: "equals",
|
||||
value: [1],
|
||||
},
|
||||
{
|
||||
identifier: "attributeId",
|
||||
attributeId: "attr-1",
|
||||
operator: "in",
|
||||
value: ["opt-1"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate a rule with empty conditions array", () => {
|
||||
const rule = {
|
||||
operator: "AND",
|
||||
conditions: [],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject a rule with invalid operator", () => {
|
||||
const rule = {
|
||||
operator: "XOR",
|
||||
conditions: [],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject a rule without operator", () => {
|
||||
const rule = {
|
||||
conditions: [],
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject a rule without conditions", () => {
|
||||
const rule = {
|
||||
operator: "AND",
|
||||
};
|
||||
|
||||
const result = attributeSyncRuleSchema.safeParse(rule);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncFormDataSchema", () => {
|
||||
const validFormData = {
|
||||
id: "sync-123",
|
||||
name: "Test Sync",
|
||||
credentialId: 1,
|
||||
enabled: true,
|
||||
organizationId: 123,
|
||||
ruleId: "rule-123",
|
||||
rule: {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
{
|
||||
identifier: "teamId",
|
||||
operator: "equals",
|
||||
value: [1],
|
||||
},
|
||||
],
|
||||
},
|
||||
syncFieldMappings: [
|
||||
{
|
||||
id: "mapping-1",
|
||||
integrationFieldName: "field1",
|
||||
attributeId: "attr-1",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("should validate valid form data with existing mappings", () => {
|
||||
const result = syncFormDataSchema.safeParse(validFormData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe("sync-123");
|
||||
expect(result.data.name).toBe("Test Sync");
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate form data with new mappings (without id)", () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
syncFieldMappings: [
|
||||
{
|
||||
integrationFieldName: "newField",
|
||||
attributeId: "attr-new",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate form data with mixed new and existing mappings", () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
syncFieldMappings: [
|
||||
{
|
||||
id: "existing-mapping",
|
||||
integrationFieldName: "existingField",
|
||||
attributeId: "attr-1",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
integrationFieldName: "newField",
|
||||
attributeId: "attr-2",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate form data with empty mappings array", () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
syncFieldMappings: [],
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject form data with empty name", () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
name: "",
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject form data without required fields", () => {
|
||||
const formData = {
|
||||
name: "Test",
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject form data with invalid rule", () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
rule: {
|
||||
operator: "INVALID",
|
||||
conditions: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject form data with invalid mapping", () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
syncFieldMappings: [
|
||||
{
|
||||
integrationFieldName: "field",
|
||||
// missing attributeId and enabled
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = syncFormDataSchema.safeParse(formData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import z from "zod";
|
||||
|
||||
import {
|
||||
ConditionIdentifierEnum,
|
||||
ConditionOperatorEnum,
|
||||
type IAttributeSyncRule,
|
||||
type IFieldMapping,
|
||||
type IFieldMappingFormState,
|
||||
type IFieldMappingWithOptionalId,
|
||||
type INewFieldMapping,
|
||||
type ISyncFormData,
|
||||
RuleOperatorEnum,
|
||||
type TAttributeSyncRuleCondition,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
|
||||
const conditionIdentifierSchema = z.nativeEnum(ConditionIdentifierEnum);
|
||||
const conditionOperatorSchema = z.nativeEnum(ConditionOperatorEnum);
|
||||
const ruleOperatorSchema = z.nativeEnum(RuleOperatorEnum);
|
||||
|
||||
// Base condition schema
|
||||
const baseConditionSchema = z.object({
|
||||
identifier: conditionIdentifierSchema,
|
||||
operator: conditionOperatorSchema,
|
||||
});
|
||||
|
||||
// Team condition schema
|
||||
const teamConditionSchema = baseConditionSchema.extend({
|
||||
identifier: z.literal(ConditionIdentifierEnum.TEAM_ID),
|
||||
value: z.array(z.number()),
|
||||
});
|
||||
|
||||
// Attribute condition schema
|
||||
const attributeConditionSchema = baseConditionSchema.extend({
|
||||
identifier: z.literal(ConditionIdentifierEnum.ATTRIBUTE_ID),
|
||||
attributeId: z.string(),
|
||||
value: z.array(z.string()),
|
||||
});
|
||||
|
||||
// Discriminated union for conditions
|
||||
export const attributeSyncRuleConditionSchema: z.ZodType<TAttributeSyncRuleCondition> =
|
||||
z.discriminatedUnion("identifier", [teamConditionSchema, attributeConditionSchema]);
|
||||
|
||||
export const attributeSyncRuleSchema: z.ZodType<IAttributeSyncRule> = z.object({
|
||||
operator: ruleOperatorSchema,
|
||||
conditions: z.array(attributeSyncRuleConditionSchema),
|
||||
});
|
||||
|
||||
// Define base schema without type annotation to preserve ZodObject methods like .extend()
|
||||
const _newFieldMappingSchema = z.object({
|
||||
integrationFieldName: z.string().min(1),
|
||||
attributeId: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
const _fieldMappingSchema = _newFieldMappingSchema.extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const _fieldMappingWithOptionalIdSchema = _newFieldMappingSchema.extend({
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
// Cast to ZodType for type safety
|
||||
export const newFieldMappingSchema: z.ZodType<INewFieldMapping> = _newFieldMappingSchema;
|
||||
export const fieldMappingSchema: z.ZodType<IFieldMapping> = _fieldMappingSchema;
|
||||
export const fieldMappingWithOptionalIdSchema: z.ZodType<IFieldMappingWithOptionalId> =
|
||||
_fieldMappingWithOptionalIdSchema;
|
||||
|
||||
const _fieldMappingFormStateSchema: z.ZodType<IFieldMappingFormState> = z.object({
|
||||
mappings: z.array(fieldMappingWithOptionalIdSchema),
|
||||
});
|
||||
|
||||
export const syncFormDataSchema: z.ZodType<ISyncFormData> = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
credentialId: z.number().optional(),
|
||||
enabled: z.boolean(),
|
||||
organizationId: z.number(),
|
||||
ruleId: z.string(),
|
||||
rule: attributeSyncRuleSchema,
|
||||
syncFieldMappings: z.array(z.union([fieldMappingSchema, newFieldMappingSchema])),
|
||||
});
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
|
||||
import { enabledAppSlugs } from "../constants";
|
||||
import type { IIntegrationAttributeSyncRepository } from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import { IntegrationAttributeSyncService, UnauthorizedAttributeError } from "./IntegrationAttributeSyncService";
|
||||
|
||||
describe("IntegrationAttributeSyncService", () => {
|
||||
let service: IntegrationAttributeSyncService;
|
||||
let mockCredentialRepository: {
|
||||
findByTeamIdAndSlugs: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockIntegrationAttributeSyncRepository: {
|
||||
getByOrganizationId: ReturnType<typeof vi.fn>;
|
||||
getById: ReturnType<typeof vi.fn>;
|
||||
getSyncFieldMappings: ReturnType<typeof vi.fn>;
|
||||
getMappedAttributeIdsByOrganization: ReturnType<typeof vi.fn>;
|
||||
getAttributeIdsByOrganization: ReturnType<typeof vi.fn>;
|
||||
updateTransactionWithRuleAndMappings: ReturnType<typeof vi.fn>;
|
||||
deleteById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockCredentialRepository = {
|
||||
findByTeamIdAndSlugs: vi.fn(),
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository = {
|
||||
getByOrganizationId: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getSyncFieldMappings: vi.fn(),
|
||||
getMappedAttributeIdsByOrganization: vi.fn(),
|
||||
getAttributeIdsByOrganization: vi.fn(),
|
||||
updateTransactionWithRuleAndMappings: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
};
|
||||
|
||||
service = new IntegrationAttributeSyncService({
|
||||
credentialRepository: mockCredentialRepository as unknown as CredentialRepository,
|
||||
integrationAttributeSyncRepository:
|
||||
mockIntegrationAttributeSyncRepository as unknown as IIntegrationAttributeSyncRepository,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEnabledAppCredentials", () => {
|
||||
it("should call credentialRepository.findByTeamIdAndSlugs with correct params", async () => {
|
||||
const organizationId = 123;
|
||||
const expectedCredentials = [
|
||||
{ id: 1, type: "salesforce", teamId: organizationId },
|
||||
{ id: 2, type: "salesforce", teamId: organizationId },
|
||||
];
|
||||
|
||||
mockCredentialRepository.findByTeamIdAndSlugs.mockResolvedValue(expectedCredentials);
|
||||
|
||||
const result = await service.getEnabledAppCredentials(organizationId);
|
||||
|
||||
expect(mockCredentialRepository.findByTeamIdAndSlugs).toHaveBeenCalledWith({
|
||||
teamId: organizationId,
|
||||
slugs: enabledAppSlugs,
|
||||
});
|
||||
expect(result).toEqual(expectedCredentials);
|
||||
});
|
||||
|
||||
it("should return empty array when no credentials found", async () => {
|
||||
const organizationId = 123;
|
||||
mockCredentialRepository.findByTeamIdAndSlugs.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEnabledAppCredentials(organizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllIntegrationAttributeSyncs", () => {
|
||||
it("should call repository.getByOrganizationId with correct organizationId", async () => {
|
||||
const organizationId = 456;
|
||||
const expectedSyncs = [
|
||||
{
|
||||
id: "sync-1",
|
||||
name: "Sync 1",
|
||||
organizationId,
|
||||
integration: "salesforce",
|
||||
enabled: true,
|
||||
attributeSyncRule: null,
|
||||
syncFieldMappings: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getByOrganizationId.mockResolvedValue(expectedSyncs);
|
||||
|
||||
const result = await service.getAllIntegrationAttributeSyncs(organizationId);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.getByOrganizationId).toHaveBeenCalledWith(organizationId);
|
||||
expect(result).toEqual(expectedSyncs);
|
||||
});
|
||||
|
||||
it("should return empty array when no syncs found", async () => {
|
||||
const organizationId = 456;
|
||||
mockIntegrationAttributeSyncRepository.getByOrganizationId.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getAllIntegrationAttributeSyncs(organizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getById", () => {
|
||||
it("should call repository.getById with correct id", async () => {
|
||||
const syncId = "sync-123";
|
||||
const expectedSync = {
|
||||
id: syncId,
|
||||
name: "Test Sync",
|
||||
organizationId: 123,
|
||||
integration: "salesforce",
|
||||
enabled: true,
|
||||
attributeSyncRule: null,
|
||||
syncFieldMappings: [],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getById.mockResolvedValue(expectedSync);
|
||||
|
||||
const result = await service.getById(syncId);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.getById).toHaveBeenCalledWith(syncId);
|
||||
expect(result).toEqual(expectedSync);
|
||||
});
|
||||
|
||||
it("should return null when sync not found", async () => {
|
||||
const syncId = "non-existent";
|
||||
mockIntegrationAttributeSyncRepository.getById.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getById(syncId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateIncludeRulesAndMappings", () => {
|
||||
const baseFormData = {
|
||||
id: "sync-123",
|
||||
name: "Test Sync",
|
||||
credentialId: 1,
|
||||
enabled: true,
|
||||
organizationId: 123,
|
||||
ruleId: "rule-123",
|
||||
rule: {
|
||||
operator: "AND" as const,
|
||||
conditions: [
|
||||
{
|
||||
identifier: "teamId" as const,
|
||||
operator: "equals" as const,
|
||||
value: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it("should create new mappings when they don't have ids", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ integrationFieldName: "field2", attributeId: "attr-2", enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateIncludeRulesAndMappings(formData);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
|
||||
integrationAttributeSync: {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
credentialId: formData.credentialId,
|
||||
enabled: formData.enabled,
|
||||
organizationId: formData.organizationId,
|
||||
},
|
||||
attributeSyncRule: {
|
||||
id: formData.ruleId,
|
||||
rule: formData.rule,
|
||||
},
|
||||
fieldMappingsToCreate: formData.syncFieldMappings,
|
||||
fieldMappingsToUpdate: [],
|
||||
fieldMappingsToDelete: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should update existing mappings when they have ids", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([
|
||||
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
|
||||
]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateIncludeRulesAndMappings(formData);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
|
||||
integrationAttributeSync: {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
credentialId: formData.credentialId,
|
||||
enabled: formData.enabled,
|
||||
organizationId: formData.organizationId,
|
||||
},
|
||||
attributeSyncRule: {
|
||||
id: formData.ruleId,
|
||||
rule: formData.rule,
|
||||
},
|
||||
fieldMappingsToCreate: [],
|
||||
fieldMappingsToUpdate: formData.syncFieldMappings,
|
||||
fieldMappingsToDelete: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete mappings that are no longer in the form data", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([
|
||||
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
|
||||
{ id: "mapping-3", integrationFieldName: "field3", attributeId: "attr-3", enabled: true },
|
||||
]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateIncludeRulesAndMappings(formData);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
|
||||
integrationAttributeSync: {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
credentialId: formData.credentialId,
|
||||
enabled: formData.enabled,
|
||||
organizationId: formData.organizationId,
|
||||
},
|
||||
attributeSyncRule: {
|
||||
id: formData.ruleId,
|
||||
rule: formData.rule,
|
||||
},
|
||||
fieldMappingsToCreate: [],
|
||||
fieldMappingsToUpdate: [formData.syncFieldMappings[0]],
|
||||
fieldMappingsToDelete: ["mapping-2", "mapping-3"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle mixed create, update, and delete operations", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ id: "mapping-1", integrationFieldName: "field1-updated", attributeId: "attr-1", enabled: false },
|
||||
{ integrationFieldName: "field-new", attributeId: "attr-new", enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-new"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([
|
||||
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
|
||||
]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateIncludeRulesAndMappings(formData);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
|
||||
integrationAttributeSync: {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
credentialId: formData.credentialId,
|
||||
enabled: formData.enabled,
|
||||
organizationId: formData.organizationId,
|
||||
},
|
||||
attributeSyncRule: {
|
||||
id: formData.ruleId,
|
||||
rule: formData.rule,
|
||||
},
|
||||
fieldMappingsToCreate: [
|
||||
{ integrationFieldName: "field-new", attributeId: "attr-new", enabled: true },
|
||||
],
|
||||
fieldMappingsToUpdate: [
|
||||
{ id: "mapping-1", integrationFieldName: "field1-updated", attributeId: "attr-1", enabled: false },
|
||||
],
|
||||
fieldMappingsToDelete: ["mapping-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle attribute-based conditions in rules", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
rule: {
|
||||
operator: "OR" as const,
|
||||
conditions: [
|
||||
{
|
||||
identifier: "attributeId" as const,
|
||||
attributeId: "attr-123",
|
||||
operator: "in" as const,
|
||||
value: ["option-1", "option-2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
syncFieldMappings: [],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await service.updateIncludeRulesAndMappings(formData);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attributeSyncRule: {
|
||||
id: formData.ruleId,
|
||||
rule: formData.rule,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAttributeOwnership", () => {
|
||||
const baseFormData = {
|
||||
id: "sync-123",
|
||||
name: "Test Sync",
|
||||
credentialId: 1,
|
||||
enabled: true,
|
||||
organizationId: 123,
|
||||
ruleId: "rule-123",
|
||||
rule: {
|
||||
operator: "AND" as const,
|
||||
conditions: [
|
||||
{
|
||||
identifier: "teamId" as const,
|
||||
operator: "equals" as const,
|
||||
value: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it("should throw UnauthorizedAttributeError when attribute does not belong to organization", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ integrationFieldName: "field2", attributeId: "attr-invalid", enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
|
||||
|
||||
await expect(service.updateIncludeRulesAndMappings(formData)).rejects.toThrow(UnauthorizedAttributeError);
|
||||
});
|
||||
|
||||
it("should include invalid attribute ids in the error", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ integrationFieldName: "field1", attributeId: "attr-valid", enabled: true },
|
||||
{ integrationFieldName: "field2", attributeId: "attr-invalid-1", enabled: true },
|
||||
{ integrationFieldName: "field3", attributeId: "attr-invalid-2", enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-valid"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
|
||||
|
||||
await expect(service.updateIncludeRulesAndMappings(formData)).rejects.toSatisfy((error) => {
|
||||
expect(error).toBeInstanceOf(UnauthorizedAttributeError);
|
||||
expect((error as UnauthorizedAttributeError).attributeIds).toEqual(["attr-invalid-1", "attr-invalid-2"]);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass validation when all attributes belong to organization", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [
|
||||
{ integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
|
||||
{ integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
|
||||
],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.updateIncludeRulesAndMappings(formData)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should pass validation when syncFieldMappings is empty", async () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
syncFieldMappings: [],
|
||||
};
|
||||
|
||||
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
|
||||
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.updateIncludeRulesAndMappings(formData)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteById", () => {
|
||||
it("should call repository.deleteById with correct id", async () => {
|
||||
const syncId = "sync-to-delete";
|
||||
mockIntegrationAttributeSyncRepository.deleteById.mockResolvedValue(undefined);
|
||||
|
||||
await service.deleteById(syncId);
|
||||
|
||||
expect(mockIntegrationAttributeSyncRepository.deleteById).toHaveBeenCalledWith(syncId);
|
||||
});
|
||||
});
|
||||
});
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
|
||||
import type { ZCreateAttributeSyncSchema } from "@calcom/trpc/server/routers/viewer/attribute-sync/createAttributeSync.schema";
|
||||
import { enabledAppSlugs } from "../constants";
|
||||
import {
|
||||
type IIntegrationAttributeSyncRepository,
|
||||
type ISyncFormData,
|
||||
AttributeSyncIntegrations,
|
||||
} from "../repositories/IIntegrationAttributeSyncRepository";
|
||||
import { attributeSyncRuleSchema } from "../schemas/zod";
|
||||
|
||||
export class DuplicateAttributeWithinSyncError extends Error {
|
||||
constructor(public attributeId: string) {
|
||||
super("Attribute is already mapped in this sync");
|
||||
this.name = "DuplicateAttributeWithinSyncError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateAttributeAcrossSyncsError extends Error {
|
||||
constructor(public attributeId: string) {
|
||||
super("Attribute is already mapped in another sync");
|
||||
this.name = "DuplicateAttributeAcrossSyncsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedAttributeError extends Error {
|
||||
constructor(public attributeIds: string[]) {
|
||||
super("One or more attributes do not belong to this organization");
|
||||
this.name = "UnauthorizedAttributeError";
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialNotFoundError extends Error {
|
||||
constructor() {
|
||||
super("Credential not found");
|
||||
this.name = "CredentialNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
interface IIntegrationAttributeSyncServiceDeps {
|
||||
credentialRepository: CredentialRepository;
|
||||
integrationAttributeSyncRepository: IIntegrationAttributeSyncRepository;
|
||||
}
|
||||
|
||||
export class IntegrationAttributeSyncService {
|
||||
constructor(private readonly deps: IIntegrationAttributeSyncServiceDeps) {}
|
||||
|
||||
private validateWithinSyncUniqueness(
|
||||
mappings: { attributeId: string }[]
|
||||
): void {
|
||||
const seenAttributes = new Set<string>();
|
||||
for (const mapping of mappings) {
|
||||
if (seenAttributes.has(mapping.attributeId)) {
|
||||
throw new DuplicateAttributeWithinSyncError(mapping.attributeId);
|
||||
}
|
||||
seenAttributes.add(mapping.attributeId);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCrossSyncUniqueness(
|
||||
organizationId: number,
|
||||
mappings: { attributeId: string }[],
|
||||
excludeSyncId?: string
|
||||
): Promise<void> {
|
||||
const existingMappedIds =
|
||||
await this.deps.integrationAttributeSyncRepository.getMappedAttributeIdsByOrganization(
|
||||
organizationId,
|
||||
excludeSyncId
|
||||
);
|
||||
const existingSet = new Set(existingMappedIds);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (existingSet.has(mapping.attributeId)) {
|
||||
throw new DuplicateAttributeAcrossSyncsError(mapping.attributeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAttributeOwnership(
|
||||
organizationId: number,
|
||||
attributeIds: string[]
|
||||
): Promise<void> {
|
||||
const validAttributeIds =
|
||||
await this.deps.integrationAttributeSyncRepository.getAttributeIdsByOrganization(
|
||||
organizationId,
|
||||
attributeIds
|
||||
);
|
||||
|
||||
if (validAttributeIds.length !== attributeIds.length) {
|
||||
const validSet = new Set(validAttributeIds);
|
||||
const invalidIds = attributeIds.filter((id) => !validSet.has(id));
|
||||
throw new UnauthorizedAttributeError(invalidIds);
|
||||
}
|
||||
}
|
||||
|
||||
async getEnabledAppCredentials(organizationId: number) {
|
||||
return this.deps.credentialRepository.findByTeamIdAndSlugs({
|
||||
teamId: organizationId,
|
||||
slugs: enabledAppSlugs,
|
||||
});
|
||||
}
|
||||
|
||||
async getAllIntegrationAttributeSyncs(organizationId: number) {
|
||||
return this.deps.integrationAttributeSyncRepository.getByOrganizationId(
|
||||
organizationId
|
||||
);
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
return this.deps.integrationAttributeSyncRepository.getById(id);
|
||||
}
|
||||
|
||||
async createAttributeSync(
|
||||
input: ZCreateAttributeSyncSchema,
|
||||
organizationId: number
|
||||
) {
|
||||
const credential = await this.deps.credentialRepository.findByIdAndTeamId({
|
||||
id: input.credentialId,
|
||||
teamId: organizationId,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new CredentialNotFoundError();
|
||||
}
|
||||
|
||||
const parsedRule = attributeSyncRuleSchema.parse(input.rule);
|
||||
|
||||
const integrationValue = credential.app?.slug || credential.type;
|
||||
if (
|
||||
!Object.values(AttributeSyncIntegrations).includes(
|
||||
integrationValue as AttributeSyncIntegrations
|
||||
)
|
||||
) {
|
||||
throw new Error(`Unsupported integration type: ${integrationValue}`);
|
||||
}
|
||||
|
||||
this.validateWithinSyncUniqueness(input.syncFieldMappings);
|
||||
|
||||
await this.validateCrossSyncUniqueness(
|
||||
organizationId,
|
||||
input.syncFieldMappings
|
||||
);
|
||||
|
||||
const attributeIds = input.syncFieldMappings.map((m) => m.attributeId);
|
||||
await this.validateAttributeOwnership(organizationId, attributeIds);
|
||||
|
||||
return this.deps.integrationAttributeSyncRepository.create({
|
||||
name: input.name,
|
||||
organizationId,
|
||||
integration: integrationValue as AttributeSyncIntegrations,
|
||||
credentialId: input.credentialId,
|
||||
enabled: input.enabled,
|
||||
rule: parsedRule,
|
||||
syncFieldMappings: input.syncFieldMappings,
|
||||
});
|
||||
}
|
||||
|
||||
async updateIncludeRulesAndMappings(data: ISyncFormData) {
|
||||
const { syncFieldMappings, rule, ruleId, ...integrationAttributeSync } =
|
||||
data;
|
||||
|
||||
this.validateWithinSyncUniqueness(syncFieldMappings);
|
||||
|
||||
await this.validateCrossSyncUniqueness(
|
||||
data.organizationId,
|
||||
syncFieldMappings,
|
||||
data.id
|
||||
);
|
||||
|
||||
const attributeIds = syncFieldMappings.map((m) => m.attributeId);
|
||||
await this.validateAttributeOwnership(data.organizationId, attributeIds);
|
||||
|
||||
const existingFieldMappings =
|
||||
await this.deps.integrationAttributeSyncRepository.getSyncFieldMappings(
|
||||
data.id
|
||||
);
|
||||
|
||||
const parsedRule = attributeSyncRuleSchema.parse(rule);
|
||||
|
||||
const incomingMappingIds = new Set(
|
||||
syncFieldMappings.reduce((ids, mapping) => {
|
||||
if ("id" in mapping) ids.push(mapping.id);
|
||||
return ids;
|
||||
}, [] as string[])
|
||||
);
|
||||
|
||||
const fieldMappingsToDelete = existingFieldMappings
|
||||
.filter((mapping) => !incomingMappingIds.has(mapping.id))
|
||||
.map((mapping) => mapping.id);
|
||||
|
||||
const fieldMappingsToCreate = syncFieldMappings.filter((m) => !("id" in m));
|
||||
const fieldMappingsToUpdate = syncFieldMappings.filter(
|
||||
(m): m is typeof m & { id: string } => "id" in m
|
||||
);
|
||||
|
||||
await this.deps.integrationAttributeSyncRepository.updateTransactionWithRuleAndMappings(
|
||||
{
|
||||
integrationAttributeSync,
|
||||
attributeSyncRule: {
|
||||
id: ruleId,
|
||||
rule: parsedRule,
|
||||
},
|
||||
fieldMappingsToCreate,
|
||||
fieldMappingsToUpdate,
|
||||
fieldMappingsToDelete,
|
||||
}
|
||||
);
|
||||
}
|
||||
async deleteById(id: string) {
|
||||
return this.deps.integrationAttributeSyncRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."IntegrationAttributeSync" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"integration" TEXT NOT NULL,
|
||||
"credentialId" INTEGER,
|
||||
"enabled" BOOLEAN NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "IntegrationAttributeSync_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."AttributeSyncRule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"integrationAttributeSyncId" TEXT NOT NULL,
|
||||
"rule" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AttributeSyncRule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."AttributeSyncFieldMapping" (
|
||||
"id" TEXT NOT NULL,
|
||||
"integrationFieldName" TEXT NOT NULL,
|
||||
"attributeId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL,
|
||||
"integrationAttributeSyncId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AttributeSyncFieldMapping_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "IntegrationAttributeSync_organizationId_idx" ON "public"."IntegrationAttributeSync"("organizationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AttributeSyncRule_integrationAttributeSyncId_key" ON "public"."AttributeSyncRule"("integrationAttributeSyncId");
|
||||
|
||||
-- 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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "public"."Credential"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."AttributeSyncRule" ADD CONSTRAINT "AttributeSyncRule_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "public"."IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "public"."Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "public"."AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "public"."IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -295,13 +295,14 @@ model Credential {
|
||||
paymentStatus String?
|
||||
billingCycleStart Int?
|
||||
|
||||
destinationCalendars DestinationCalendar[]
|
||||
selectedCalendars SelectedCalendar[]
|
||||
invalid Boolean? @default(false)
|
||||
CalendarCache CalendarCache[]
|
||||
references BookingReference[]
|
||||
delegationCredentialId String?
|
||||
delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade)
|
||||
destinationCalendars DestinationCalendar[]
|
||||
selectedCalendars SelectedCalendar[]
|
||||
invalid Boolean? @default(false)
|
||||
CalendarCache CalendarCache[]
|
||||
references BookingReference[]
|
||||
delegationCredentialId String?
|
||||
delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade)
|
||||
integrationAttributeSyncs IntegrationAttributeSync[]
|
||||
|
||||
@@index([appId])
|
||||
@@index([subscriptionId])
|
||||
@@ -614,8 +615,9 @@ model Team {
|
||||
managedOrganizations ManagedOrganization[] @relation("ManagerOrganization")
|
||||
filterSegments FilterSegment[]
|
||||
|
||||
teamBilling TeamBilling? @relation("TeamBilling")
|
||||
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
|
||||
teamBilling TeamBilling? @relation("TeamBilling")
|
||||
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
|
||||
integrationAttributeSyncs IntegrationAttributeSync[]
|
||||
|
||||
autoOptInFeatures Boolean @default(false)
|
||||
|
||||
@@ -2230,11 +2232,12 @@ model Attribute {
|
||||
|
||||
usersCanEditRelation Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
options AttributeOption[]
|
||||
isWeightsEnabled Boolean @default(false)
|
||||
isLocked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
options AttributeOption[]
|
||||
isWeightsEnabled Boolean @default(false)
|
||||
isLocked Boolean @default(false)
|
||||
attributeSyncFieldMappings AttributeSyncFieldMapping[]
|
||||
|
||||
@@unique([teamId, slug])
|
||||
@@index([teamId])
|
||||
@@ -3103,3 +3106,54 @@ model CalendarCacheEvent {
|
||||
@@index([start, end, status])
|
||||
@@index([selectedCalendarId, iCalUID])
|
||||
}
|
||||
|
||||
model IntegrationAttributeSync {
|
||||
id String @id @default(uuid())
|
||||
organizationId Int
|
||||
organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
integration String
|
||||
credentialId Int?
|
||||
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade)
|
||||
|
||||
enabled Boolean
|
||||
|
||||
attributeSyncRule AttributeSyncRule?
|
||||
syncFieldMappings AttributeSyncFieldMapping[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([credentialId])
|
||||
}
|
||||
|
||||
model AttributeSyncRule {
|
||||
id String @id @default(uuid())
|
||||
|
||||
integrationAttributeSyncId String @unique
|
||||
integrationAttributeSync IntegrationAttributeSync @relation(fields: [integrationAttributeSyncId], references: [id], onDelete: Cascade)
|
||||
|
||||
rule Json
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AttributeSyncFieldMapping {
|
||||
id String @id @default(uuid())
|
||||
integrationFieldName String
|
||||
attributeId String
|
||||
attribute Attribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
|
||||
|
||||
enabled Boolean
|
||||
integrationAttributeSyncId String
|
||||
integrationAttributeSync IntegrationAttributeSync @relation(fields: [integrationAttributeSyncId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([integrationAttributeSyncId, attributeId])
|
||||
@@index([integrationAttributeSyncId])
|
||||
}
|
||||
|
||||
@@ -45,4 +45,5 @@ export const ENDPOINTS = [
|
||||
"aiVoiceAgent",
|
||||
"phoneNumber",
|
||||
"pbac",
|
||||
"attributeSync",
|
||||
] as const;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { aiVoiceAgentRouter } from "./aiVoiceAgent/_router";
|
||||
import { apiKeysRouter } from "./apiKeys/_router";
|
||||
import { appsRouter } from "./apps/_router";
|
||||
import { attributesRouter } from "./attributes/_router";
|
||||
import { attributeSyncRouter } from "./attribute-sync/_router";
|
||||
import { authRouter } from "./auth/_router";
|
||||
import { availabilityRouter } from "./availability/_router";
|
||||
import { bookingsRouter } from "./bookings/_router";
|
||||
@@ -83,6 +84,7 @@ export const viewerRouter = router({
|
||||
googleWorkspace: googleWorkspaceRouter,
|
||||
admin: adminRouter,
|
||||
attributes: attributesRouter,
|
||||
attributeSync: attributeSyncRouter,
|
||||
routingForms: routingFormsRouter,
|
||||
credits: creditsRouter,
|
||||
ooo: oooRouter,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { router } from "../../../trpc";
|
||||
import { createAttributeSyncSchema } from "./createAttributeSync.schema";
|
||||
import { deleteAttributeSyncSchema } from "./deleteAttributeSync.schema";
|
||||
import { getAllAttributeSyncsSchema } from "./getAllAttributeSyncs.schema";
|
||||
import { updateAttributeSyncSchema } from "./updateAttributeSync.schema";
|
||||
import { createAttributePbacProcedure } from "./util";
|
||||
|
||||
export const attributeSyncRouter = router({
|
||||
getAllAttributeSyncs: createAttributePbacProcedure("organization.attributes.read")
|
||||
.input(getAllAttributeSyncsSchema)
|
||||
.query(async (opts) => {
|
||||
const { default: handler } = await import("./getAllAttributeSyncs.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
createAttributeSync: createAttributePbacProcedure("organization.attributes.create")
|
||||
.input(createAttributeSyncSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./createAttributeSync.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
updateAttributeSync: createAttributePbacProcedure("organization.attributes.update")
|
||||
.input(updateAttributeSyncSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./updateAttributeSync.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
deleteAttributeSync: createAttributePbacProcedure("organization.attributes.delete")
|
||||
.input(deleteAttributeSyncSchema)
|
||||
.mutation(async (opts) => {
|
||||
const { default: handler } = await import("./deleteAttributeSync.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getIntegrationAttributeSyncService } from "@calcom/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
|
||||
import {
|
||||
CredentialNotFoundError,
|
||||
DuplicateAttributeWithinSyncError,
|
||||
DuplicateAttributeAcrossSyncsError,
|
||||
} from "@calcom/ee/integration-attribute-sync/services/IntegrationAttributeSyncService";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { ZCreateAttributeSyncSchema } from "./createAttributeSync.schema";
|
||||
|
||||
type CreateAttributeSyncOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: ZCreateAttributeSyncSchema;
|
||||
};
|
||||
|
||||
const createAttributeSyncHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: CreateAttributeSyncOptions) => {
|
||||
const org = ctx.user.organization;
|
||||
|
||||
if (!org?.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to be part of an organization to use this feature",
|
||||
});
|
||||
}
|
||||
|
||||
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
|
||||
|
||||
try {
|
||||
return await integrationAttributeSyncService.createAttributeSync(
|
||||
input,
|
||||
org.id
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof CredentialNotFoundError) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Credential not found",
|
||||
});
|
||||
}
|
||||
if (error instanceof DuplicateAttributeWithinSyncError) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Each attribute can only be mapped once per sync. Please remove duplicate attribute mappings.",
|
||||
});
|
||||
}
|
||||
if (error instanceof DuplicateAttributeAcrossSyncsError) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"An attribute is already mapped in another sync. Each attribute can only be synced from one source.",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default createAttributeSyncHandler;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ICreateAttributeSyncInput } from "@calcom/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository";
|
||||
import {
|
||||
attributeSyncRuleSchema,
|
||||
newFieldMappingSchema,
|
||||
} from "@calcom/features/ee/integration-attribute-sync/schemas/zod";
|
||||
|
||||
export const createAttributeSyncSchema: z.ZodType<ICreateAttributeSyncInput> = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
credentialId: z.number(),
|
||||
rule: attributeSyncRuleSchema,
|
||||
syncFieldMappings: z.array(newFieldMappingSchema),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type ZCreateAttributeSyncSchema = ICreateAttributeSyncInput;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getIntegrationAttributeSyncService } from "@calcom/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { ZDeleteAttributeSyncSchema } from "./deleteAttributeSync.schema";
|
||||
|
||||
type DeleteAttributeSyncOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: ZDeleteAttributeSyncSchema;
|
||||
};
|
||||
|
||||
const deleteAttributeSyncHandler = async ({ ctx, input }: DeleteAttributeSyncOptions) => {
|
||||
const currentUserOrgId = ctx.user.organizationId;
|
||||
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
|
||||
|
||||
const integrationAttributeSync = await integrationAttributeSyncService.getById(input.id);
|
||||
|
||||
if (!integrationAttributeSync) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
if (integrationAttributeSync.organizationId !== currentUserOrgId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
await integrationAttributeSyncService.deleteById(input.id);
|
||||
};
|
||||
|
||||
export default deleteAttributeSyncHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const deleteAttributeSyncSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type ZDeleteAttributeSyncSchema = z.infer<typeof deleteAttributeSyncSchema>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { ZGetAllAttributeSyncsSchema } from "./getAllAttributeSyncs.schema";
|
||||
|
||||
type GetAllAttributeSyncsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: ZGetAllAttributeSyncsSchema;
|
||||
};
|
||||
|
||||
const getAllAttributeSyncsHandler = async ({ ctx }: GetAllAttributeSyncsOptions) => {
|
||||
const org = ctx.user.organization;
|
||||
|
||||
if (!org?.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to be part of an organization to use this feature",
|
||||
});
|
||||
}
|
||||
|
||||
const service = getIntegrationAttributeSyncService();
|
||||
|
||||
return await service.getAllIntegrationAttributeSyncs(org.id);
|
||||
};
|
||||
|
||||
export default getAllAttributeSyncsHandler;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// No input required - organizationId comes from context
|
||||
export const getAllAttributeSyncsSchema = z.object({});
|
||||
|
||||
export type ZGetAllAttributeSyncsSchema = z.infer<typeof getAllAttributeSyncsSchema>;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getIntegrationAttributeSyncService } from "@calcom/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
|
||||
import {
|
||||
DuplicateAttributeWithinSyncError,
|
||||
DuplicateAttributeAcrossSyncsError,
|
||||
} from "@calcom/ee/integration-attribute-sync/services/IntegrationAttributeSyncService";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import { ZUpdateAttributeSyncSchema } from "./updateAttributeSync.schema";
|
||||
|
||||
type UpdateAttributeSyncOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: ZUpdateAttributeSyncSchema;
|
||||
};
|
||||
|
||||
const updateAttributeSyncHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: UpdateAttributeSyncOptions) => {
|
||||
const org = ctx.user.organization;
|
||||
|
||||
if (!org?.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to be part of an organization to use this feature",
|
||||
});
|
||||
}
|
||||
|
||||
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
|
||||
|
||||
// Verify the sync exists and belongs to the user's organization
|
||||
const integrationAttributeSync =
|
||||
await integrationAttributeSyncService.getById(input.id);
|
||||
|
||||
if (!integrationAttributeSync) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
if (integrationAttributeSync.organizationId !== org.id) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
try {
|
||||
await integrationAttributeSyncService.updateIncludeRulesAndMappings(input);
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateAttributeWithinSyncError) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Each attribute can only be mapped once per sync. Please remove duplicate attribute mappings.",
|
||||
});
|
||||
}
|
||||
if (error instanceof DuplicateAttributeAcrossSyncsError) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"This attribute is already mapped in another sync. Each attribute can only be synced from one source.",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default updateAttributeSyncHandler;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { IUpdateAttributeSyncInput } from "@calcom/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository";
|
||||
import {
|
||||
attributeSyncRuleSchema,
|
||||
fieldMappingWithOptionalIdSchema,
|
||||
} from "@calcom/features/ee/integration-attribute-sync/schemas/zod";
|
||||
|
||||
export const updateAttributeSyncSchema: z.ZodType<IUpdateAttributeSyncInput> = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
credentialId: z.number().optional(),
|
||||
enabled: z.boolean(),
|
||||
organizationId: z.number(),
|
||||
ruleId: z.string(),
|
||||
rule: attributeSyncRuleSchema,
|
||||
syncFieldMappings: z.array(fieldMappingWithOptionalIdSchema),
|
||||
});
|
||||
|
||||
export type ZUpdateAttributeSyncSchema = IUpdateAttributeSyncInput;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Re-export the attribute PBAC procedure for reuse in attribute-sync endpoints
|
||||
export { createAttributePbacProcedure } from "../attributes/util";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -34,10 +35,9 @@ export async function getTeamsHandler({ ctx }: GetTeamsHandler) {
|
||||
});
|
||||
}
|
||||
|
||||
const allOrgTeams = await prisma.team.findMany({
|
||||
where: {
|
||||
parentId: currentUserOrgId,
|
||||
},
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const allOrgTeams = await teamRepository.findAllByParentId({
|
||||
parentId: currentUserOrgId,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function FormCard({
|
||||
badge,
|
||||
collapsible = true,
|
||||
leftIcon,
|
||||
customActions,
|
||||
...restProps
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
@@ -117,6 +118,7 @@ export default function FormCard({
|
||||
badge?: { text: string; href?: string; variant: BadgeProps["variant"] } | null;
|
||||
leftIcon?: IconName;
|
||||
collapsible?: boolean;
|
||||
customActions?: React.ReactNode;
|
||||
} & JSX.IntrinsicElements["div"]) {
|
||||
className = classNames(
|
||||
"flex items-center group relative w-full rounded-2xl p-1 border border-subtle bg-cal-muted mb-2",
|
||||
@@ -192,7 +194,8 @@ export default function FormCard({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{customActions}
|
||||
<FormCardActions deleteField={deleteField} duplicateField={duplicateField} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user