Files
calendar/packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx
T
Joe Au-YeungGitHubjoe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Eunjae Lee
8dbe3848e7 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>
2026-01-12 22:57:28 +00:00

496 lines
14 KiB
TypeScript

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>
);
};