Skip to content

Commit 8dbe384

Browse files
joeauyeungdevin-ai-integration[bot]eunjae-lee
authored
feat: Create Integration Attribute Sync records (calcom#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 (calcom#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>
1 parent 9202028 commit 8dbe384

40 files changed

Lines changed: 3524 additions & 19 deletions
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { _generateMetadata, getTranslate } from "app/_utils";
2+
3+
import IntegrationAttributeSyncView from "@calcom/features/ee/integration-attribute-sync/IntegrationAttributeSyncView";
4+
import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
5+
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
6+
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
7+
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
8+
import { prisma } from "@calcom/prisma";
9+
10+
import { validateUserHasOrgPerms } from "../../../actions/validateUserHasOrgPerms";
11+
12+
export const generateMetadata = async () =>
13+
await _generateMetadata(
14+
(t) => t("attribute_sync"),
15+
(t) => t("attribute_sync_description"),
16+
undefined,
17+
undefined,
18+
"/settings/organizations/attributes/sync"
19+
);
20+
21+
const Page = async () => {
22+
const t = await getTranslate();
23+
24+
const session = await validateUserHasOrgPerms({
25+
permission: "organization.attributes.create",
26+
fallbackRoles: ["ADMIN", "OWNER"],
27+
redirectTo: "/settings/my-account/profile",
28+
});
29+
30+
const organizationId = session.user.org.id;
31+
32+
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
33+
const teamRepository = new TeamRepository(prisma);
34+
const attributeRepo = new PrismaAttributeRepository(prisma);
35+
36+
const [credentialData, integrationAttributeSyncs, organizationTeams, attributes] = await Promise.all([
37+
integrationAttributeSyncService.getEnabledAppCredentials(organizationId),
38+
integrationAttributeSyncService.getAllIntegrationAttributeSyncs(organizationId),
39+
teamRepository.findAllByParentId({
40+
parentId: organizationId,
41+
select: {
42+
id: true,
43+
name: true,
44+
},
45+
}),
46+
attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId }),
47+
]);
48+
49+
return (
50+
<SettingsHeader title={t("attribute_sync")} description={t("attribute_sync_description")}>
51+
<IntegrationAttributeSyncView
52+
credentialsData={credentialData}
53+
initialIntegrationAttributeSyncs={integrationAttributeSyncs}
54+
organizationTeams={organizationTeams}
55+
attributes={attributes}
56+
organizationId={organizationId}
57+
/>
58+
</SettingsHeader>
59+
);
60+
};
61+
62+
export default Page;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
2+
import { attributeSyncRouter } from "@calcom/trpc/server/routers/viewer/attribute-sync/_router";
3+
4+
export default createNextApiHandler(attributeSyncRouter);

apps/web/public/static/locales/en/common.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@
374374
"done": "Done",
375375
"all_done": "All done!",
376376
"all": "All",
377+
"any": "Any",
377378
"yours": "Your account",
378379
"available_apps": "Available Apps",
379380
"available_apps_lower_case": "Available apps",
@@ -3101,6 +3102,47 @@
31013102
"back_to_attributes": "Back to attributes",
31023103
"delete_attribute": "Are you sure you want to delete this option?",
31033104
"delete_attribute_description": "This option is assigned to {{numberOfUsers}} members. Deleting it will remove it from their profile.",
3105+
"attribute_sync": "Attribute Sync",
3106+
"attribute_sync_description": "Setup attribute syncing with 3rd party integrations",
3107+
"attribute_sync_new_integration_sync": "New Integration Sync",
3108+
"attribute_sync_credential": "Credential",
3109+
"attribute_sync_credential_required": "Credential is required",
3110+
"attribute_sync_credential_validation": "Select a credential for the attribute sync",
3111+
"attribute_sync_select_credential": "Select a credential...",
3112+
"attribute_sync_credential_description": "Choose which integration credential to use for syncing attributes",
3113+
"attribute_sync_user_filter_rules": "User Filter Rules",
3114+
"attribute_sync_user_filter_rules_description": "Define which users to sync based on team membership or attributes",
3115+
"attribute_sync_field_mappings": "Field Mappings",
3116+
"attribute_sync_duplicate_field_mapping": "Duplicate field name and attribute combination found",
3117+
"attribute_sync_duplicate_attribute_mapping": "Each attribute can only be mapped once",
3118+
"attribute_sync_field_mappings_description": "Map integration field names to Cal.com attributes for syncing",
3119+
"attribute_sync_created_successfully": "Attribute sync created successfully",
3120+
"attribute_sync_updated_successfully": "Attribute sync updated successfully",
3121+
"attribute_sync_deleted_successfully": "Attribute sync deleted successfully",
3122+
"attribute_sync_delete_title": "Delete Attribute Sync",
3123+
"attribute_sync_delete_confirmation": "Are you sure you want to delete this sync? This action cannot be undone.",
3124+
"attribute_sync_no_mappings": "No field mappings added. Click below to add your first mapping.",
3125+
"attribute_sync_add_mapping": "Add mapping",
3126+
"attribute_sync_field_placeholder": "e.g., Department, Title...",
3127+
"attribute_sync_select_attribute": "Select Cal.com attribute...",
3128+
"attribute_sync_add_new": "Add new sync",
3129+
"attribute_sync_select_teams": "Select team(s)...",
3130+
"attribute_sync_select_attribute_first": "Select an attribute first",
3131+
"attribute_sync_select_values": "Select value(s)...",
3132+
"attribute_sync_enter_text_value": "Enter text value...",
3133+
"attribute_sync_enter_number_value": "Enter number value...",
3134+
"attribute_sync_sync_users_where": "Sync users where",
3135+
"attribute_sync_conditions_match": "conditions match:",
3136+
"attribute_sync_no_conditions": "No conditions added. Click below to add your first condition.",
3137+
"attribute_sync_add_condition": "Add condition",
3138+
"attribute_sync_operator_is_any_of": "is any of",
3139+
"attribute_sync_operator_is_not_any_of": "is not any of",
3140+
"attribute_sync_operator_is": "is",
3141+
"attribute_sync_operator_is_not": "is not",
3142+
"attribute_sync_operator_includes_any_of": "includes any of",
3143+
"attribute_sync_operator_does_not_include": "does not include",
3144+
"attribute_sync_operator_equals": "equals",
3145+
"attribute_sync_operator_not_equals": "not equals",
31043146
"disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type",
31053147
"disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.",
31063148
"disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type",

packages/features/credentials/repositories/CredentialRepository.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,33 @@ export class CredentialRepository {
300300
},
301301
});
302302
}
303+
304+
findByTeamIdAndSlugs({ teamId, slugs }: { teamId: number; slugs: string[] }) {
305+
return this.primaClient.credential.findMany({
306+
where: {
307+
teamId,
308+
appId: {
309+
in: slugs,
310+
},
311+
},
312+
select: { ...safeCredentialSelect, team: { select: { name: true } } },
313+
});
314+
}
315+
316+
findByIdAndTeamId({ id, teamId }: { id: number; teamId: number }) {
317+
return this.primaClient.credential.findFirst({
318+
where: {
319+
id,
320+
teamId,
321+
},
322+
select: {
323+
...safeCredentialSelect,
324+
app: {
325+
select: {
326+
slug: true,
327+
},
328+
},
329+
},
330+
});
331+
}
303332
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
7+
import { trpc } from "@calcom/trpc/react";
8+
import { Button } from "@calcom/ui/components/button";
9+
10+
import EditIntegrationAttributeSyncCard from "./components/EditIntegrationAttributeSyncCard";
11+
import NewIntegrationAttributeSyncCard from "./components/NewIntegrationAttributeSyncCard";
12+
import type { IntegrationAttributeSync } from "./repositories/IIntegrationAttributeSyncRepository";
13+
14+
interface IIntegrationAttributeSyncViewProps {
15+
credentialsData: {
16+
id: number;
17+
type: string;
18+
team: {
19+
name: string;
20+
} | null;
21+
}[];
22+
initialIntegrationAttributeSyncs: IntegrationAttributeSync[];
23+
organizationTeams: {
24+
id: number;
25+
name: string;
26+
}[];
27+
attributes: Attribute[];
28+
organizationId: number;
29+
}
30+
31+
const IntegrationAttributeSyncView = (props: IIntegrationAttributeSyncViewProps) => {
32+
const { credentialsData, initialIntegrationAttributeSyncs, organizationTeams, attributes, organizationId } =
33+
props;
34+
const { t } = useLocale();
35+
36+
const [showNewSync, setShowNewSync] = useState(false);
37+
38+
const { data: integrationAttributeSyncs } = trpc.viewer.attributeSync.getAllAttributeSyncs.useQuery(
39+
{},
40+
{
41+
initialData: initialIntegrationAttributeSyncs,
42+
}
43+
);
44+
45+
const credentialOptions =
46+
credentialsData?.map((cred) => ({
47+
value: String(cred.id),
48+
label: `${cred.type} ${cred.team?.name ? `(${cred.team.name})` : ""}`,
49+
})) ?? [];
50+
51+
const teamOptions =
52+
organizationTeams?.map((team) => ({
53+
value: String(team.id),
54+
label: team.name,
55+
})) ?? [];
56+
57+
const attributeOptions =
58+
attributes?.map((attr) => ({
59+
value: attr.id,
60+
label: attr.name,
61+
})) ?? [];
62+
63+
const handleAddNewSync = () => {
64+
setShowNewSync(true);
65+
};
66+
67+
const handleCancel = () => {
68+
setShowNewSync(false);
69+
};
70+
71+
return (
72+
<>
73+
{integrationAttributeSyncs && integrationAttributeSyncs.length > 0 && (
74+
<div className="mb-4 space-y-2">
75+
{integrationAttributeSyncs.map((sync) => (
76+
<EditIntegrationAttributeSyncCard
77+
key={sync.id}
78+
sync={sync}
79+
credentialOptions={credentialOptions}
80+
teamOptions={teamOptions}
81+
attributes={attributes}
82+
attributeOptions={attributeOptions}
83+
organizationId={organizationId}
84+
/>
85+
))}
86+
</div>
87+
)}
88+
89+
{showNewSync && (
90+
<NewIntegrationAttributeSyncCard
91+
credentialOptions={credentialOptions}
92+
teamOptions={teamOptions}
93+
attributes={attributes}
94+
attributeOptions={attributeOptions}
95+
organizationId={organizationId}
96+
onCancel={handleCancel}
97+
/>
98+
)}
99+
<Button StartIcon="plus" onClick={handleAddNewSync}>
100+
{t("attribute_sync_add_new")}
101+
</Button>
102+
</>
103+
);
104+
};
105+
106+
export default IntegrationAttributeSyncView;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useLocale } from "@calcom/lib/hooks/useLocale";
2+
import { trpc } from "@calcom/trpc/react";
3+
import { showToast } from "@calcom/ui/components/toast";
4+
5+
import type { IntegrationAttributeSync, ISyncFormData } from "../repositories/IIntegrationAttributeSyncRepository";
6+
import IntegrationAttributeSyncCard from "./IntegrationAttributeSyncCard";
7+
import type { IIntegrationAttributeSyncCardProps } from "./IntegrationAttributeSyncCard";
8+
9+
type IEditIntegrationAttributeSyncCardProps = Pick<
10+
IIntegrationAttributeSyncCardProps,
11+
"credentialOptions" | "teamOptions" | "attributes" | "organizationId" | "attributeOptions"
12+
> & {
13+
sync: IntegrationAttributeSync;
14+
};
15+
16+
const EditIntegrationAttributeSyncCard = (props: IEditIntegrationAttributeSyncCardProps) => {
17+
const { t } = useLocale();
18+
const utils = trpc.useUtils();
19+
const updateMutation = trpc.viewer.attributeSync.updateAttributeSync.useMutation({
20+
onSuccess: () => {
21+
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
22+
showToast(t("attribute_sync_updated_successfully"), "success");
23+
},
24+
onError: (error) => {
25+
showToast(error.message, "error");
26+
},
27+
});
28+
29+
const deleteMutation = trpc.viewer.attributeSync.deleteAttributeSync.useMutation({
30+
onSuccess: () => {
31+
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
32+
showToast(t("attribute_sync_deleted_successfully"), "success");
33+
},
34+
onError: (error) => {
35+
showToast(error.message, "error");
36+
},
37+
});
38+
39+
const onSubmit = (data: ISyncFormData) => {
40+
updateMutation.mutate({ ...data });
41+
};
42+
43+
const onDelete = () => {
44+
deleteMutation.mutate({ id: props.sync.id });
45+
};
46+
47+
return (
48+
<IntegrationAttributeSyncCard
49+
{...props}
50+
onSubmit={onSubmit}
51+
onDelete={onDelete}
52+
isSubmitting={updateMutation.isPending}
53+
/>
54+
);
55+
};
56+
57+
export default EditIntegrationAttributeSyncCard;

0 commit comments

Comments
 (0)