fix: Duplicate calls syncing Calendar integrations (#26858)

* fix: Duplicate calls syncing Calendar integrations

* fix: TS error in handler

* Minor fixup

* Set cacheUpdatedAt at correct depth of array

* Import RouterOutputs
This commit is contained in:
Alex van Andel
2026-01-15 00:21:40 -03:00
committed by GitHub
parent a708e088c9
commit 1dd2652080
8 changed files with 164 additions and 133 deletions
@@ -1,27 +1,32 @@
"use client";
import { useEffect, Suspense } from "react";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { DestinationCalendarSettingsWebWrapper } from "./DestinationCalendarSettingsWebWrapper";
import { SelectedCalendarsSettingsWebWrapper } from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
import AppListCard from "@calcom/web/modules/apps/components/AppListCard";
import { SkeletonLoader } from "@calcom/web/modules/apps/components/SkeletonLoader";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { ShellSubHeading } from "@calcom/ui/components/layout";
import { List } from "@calcom/ui/components/list";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateSettingsCalendars } from "@calcom/web/app/cache/path/settings/my-account";
import { QueryCell } from "@lib/QueryCell";
import AppListCard from "@calcom/web/modules/apps/components/AppListCard";
import { SkeletonLoader } from "@calcom/web/modules/apps/components/SkeletonLoader";
import { SelectedCalendarsSettingsWebWrapper } from "@calcom/web/modules/calendars/components/SelectedCalendarsSettingsWebWrapper";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
import { QueryCell } from "@lib/QueryCell";
import { Suspense, useEffect } from "react";
import { DestinationCalendarSettingsWebWrapper } from "./DestinationCalendarSettingsWebWrapper";
type CalendarListContainerProps = {
connectedCalendars: RouterOutputs["viewer"]["calendars"]["connectedCalendars"];
installedCalendars: RouterOutputs["viewer"]["apps"]["integrations"];
heading?: boolean;
fromOnboarding?: boolean;
};
type Props = {
onChanged: () => unknown | Promise<unknown>;
@@ -30,7 +35,7 @@ type Props = {
isPending?: boolean;
};
function CalendarList(props: Props) {
function CalendarList(props: Props): JSX.Element {
const { t } = useLocale();
const query = trpc.viewer.apps.integrations.useQuery({ variant: "calendar", onlyInstalled: false });
@@ -66,18 +71,16 @@ function CalendarList(props: Props) {
);
}
const AddCalendarButton = () => {
const AddCalendarButton = (): JSX.Element => {
const { t } = useLocale();
return (
<>
<Button color="secondary" StartIcon="plus" href="/apps/categories/calendar">
{t("add_calendar")}
</Button>
</>
<Button color="secondary" StartIcon="plus" href="/apps/categories/calendar">
{t("add_calendar")}
</Button>
);
};
export const CalendarListContainerSkeletonLoader = () => {
export const CalendarListContainerSkeletonLoader = (): JSX.Element => {
const { t } = useLocale();
return (
<SettingsHeader
@@ -89,19 +92,12 @@ export const CalendarListContainerSkeletonLoader = () => {
);
};
type CalendarListContainerProps = {
connectedCalendars: RouterOutputs["viewer"]["calendars"]["connectedCalendars"];
installedCalendars: RouterOutputs["viewer"]["apps"]["integrations"];
heading?: boolean;
fromOnboarding?: boolean;
};
export function CalendarListContainer({
connectedCalendars: data,
installedCalendars,
heading = true,
fromOnboarding,
}: CalendarListContainerProps) {
}: CalendarListContainerProps): JSX.Element {
const { t } = useLocale();
const { error, setQuery: setError } = useRouterQuery("error");
@@ -113,7 +109,7 @@ export function CalendarListContainer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const utils = trpc.useUtils();
const onChanged = () =>
const onChanged = (): void => {
Promise.allSettled([
utils.viewer.apps.integrations.invalidate(
{ variant: "calendar", onlyInstalled: true },
@@ -124,7 +120,7 @@ export function CalendarListContainer({
utils.viewer.calendars.connectedCalendars.invalidate(),
revalidateSettingsCalendars(),
]);
};
const mutation = trpc.viewer.calendars.setDestinationCalendar.useMutation({
onSuccess: () => {
utils.viewer.calendars.connectedCalendars.invalidate();
@@ -132,51 +128,61 @@ export function CalendarListContainer({
},
});
let content = null;
if (!!data.connectedCalendars.length || !!installedCalendars?.items.length) {
let headingContent = null;
if (heading) {
headingContent = (
<>
<DestinationCalendarSettingsWebWrapper connectedCalendars={data} />
<Suspense fallback={<SkeletonLoader />}>
<SelectedCalendarsSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isPending={mutation.isPending}
connectedCalendars={data}
/>
</Suspense>
</>
);
}
content = headingContent;
} else if (fromOnboarding) {
content = (
<>
{!!data?.connectedCalendars.length && (
<ShellSubHeading
className="mt-4"
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
);
} else {
content = (
<EmptyScreen
Icon="calendar"
headline={t("no_category_apps", {
category: t("calendar").toLowerCase(),
})}
description={t(`no_category_apps_description_calendar`)}
buttonRaw={
<Button color="secondary" data-testid="connect-calendar-apps" href="/apps/categories/calendar">
{t(`connect_calendar_apps`)}
</Button>
}
/>
);
}
return (
<SettingsHeader
title={t("calendars")}
description={t("calendars_description")}
CTA={<AddCalendarButton />}>
{!!data.connectedCalendars.length || !!installedCalendars?.items.length ? (
<>
{heading && (
<>
<DestinationCalendarSettingsWebWrapper />
<Suspense fallback={<SkeletonLoader />}>
<SelectedCalendarsSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isPending={mutation.isPending}
/>
</Suspense>
</>
)}
</>
) : fromOnboarding ? (
<>
{!!data?.connectedCalendars.length && (
<ShellSubHeading
className="mt-4"
title={<SubHeadingTitleWithConnections title={t("connect_additional_calendar")} />}
/>
)}
<CalendarList onChanged={onChanged} />
</>
) : (
<EmptyScreen
Icon="calendar"
headline={t("no_category_apps", {
category: t("calendar").toLowerCase(),
})}
description={t(`no_category_apps_description_calendar`)}
buttonRaw={
<Button color="secondary" data-testid="connect-calendar-apps" href="/apps/categories/calendar">
{t(`connect_calendar_apps`)}
</Button>
}
/>
)}
{content}
</SettingsHeader>
);
}
@@ -1,17 +1,22 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import {
reminderSchema,
type ReminderMinutes,
reminderSchema,
} from "@calcom/trpc/server/routers/viewer/calendars/setDestinationReminder.schema";
import { showToast } from "@calcom/ui/components/toast";
import { AtomsWrapper } from "../../../../packages/platform/atoms/src/components/atoms-wrapper";
import { DestinationCalendarSettings } from "../../../../packages/platform/atoms/destination-calendar/DestinationCalendar";
export const DestinationCalendarSettingsWebWrapper = () => {
import { AtomsWrapper } from "../../../../packages/platform/atoms/src/components/atoms-wrapper";
export const DestinationCalendarSettingsWebWrapper = ({
connectedCalendars,
}: {
connectedCalendars?: RouterOutputs["viewer"]["calendars"]["connectedCalendars"];
}): JSX.Element | null => {
const { t } = useLocale();
const calendars = trpc.viewer.calendars.connectedCalendars.useQuery();
const calendars = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
initialData: connectedCalendars,
});
const utils = trpc.useUtils();
const mutation = trpc.viewer.calendars.setDestinationCalendar.useMutation({
onSuccess: () => {
@@ -33,7 +38,7 @@ export const DestinationCalendarSettingsWebWrapper = () => {
return null;
}
const handleReminderChange = (value: ReminderMinutes) => {
const handleReminderChange = (value: ReminderMinutes): void => {
const destCal = calendars.data.destinationCalendar;
if (destCal?.credentialId) {
reminderMutation.mutate({
@@ -47,9 +52,10 @@ export const DestinationCalendarSettingsWebWrapper = () => {
const validatedReminderValue = reminderSchema.safeParse(
calendars.data.destinationCalendar.customCalendarReminder
);
const reminderValue: ReminderMinutes = validatedReminderValue.success
? validatedReminderValue.data
: null;
let reminderValue: ReminderMinutes = null;
if (validatedReminderValue.success) {
reminderValue = validatedReminderValue.data;
}
return (
<AtomsWrapper>
@@ -1,8 +1,4 @@
import Link from "next/link";
import React from "react";
import AppListCard from "@calcom/web/modules/apps/components/AppListCard";
import CredentialActionsDropdown from "@calcom/web/modules/apps/components/CredentialActionsDropdown";
import { SelectedCalendarsSettings } from "@calcom/atoms/selected-calendars/SelectedCalendarsSettings";
import AdditionalCalendarSelector from "@calcom/features/calendars/AdditionalCalendarSelector";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -11,8 +7,10 @@ import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Select } from "@calcom/ui/components/form";
import { List } from "@calcom/ui/components/list";
import { SelectedCalendarsSettings } from "@calcom/atoms/selected-calendars/SelectedCalendarsSettings";
import AppListCard from "@calcom/web/modules/apps/components/AppListCard";
import CredentialActionsDropdown from "@calcom/web/modules/apps/components/CredentialActionsDropdown";
import Link from "next/link";
import React from "react";
export enum SelectedCalendarSettingsScope {
User = "user",
@@ -30,6 +28,7 @@ type SelectedCalendarsSettingsWebWrapperProps = {
scope?: SelectedCalendarSettingsScope;
setScope?: (scope: SelectedCalendarSettingsScope) => void;
disableConnectionModification?: boolean;
connectedCalendars?: RouterOutputs["viewer"]["calendars"]["connectedCalendars"];
};
const ConnectedCalendarList = ({
@@ -92,7 +91,12 @@ const ConnectedCalendarList = ({
isChecked={cal.isSelected}
destination={cal.externalId === destinationCalendarId}
credentialId={cal.credentialId}
eventTypeId={shouldUseEventTypeScope ? eventTypeId : null}
eventTypeId={(() => {
if (shouldUseEventTypeScope) {
return eventTypeId;
}
return null;
})()}
delegationCredentialId={connectedCalendar.delegationCredentialId || null}
/>
))}
@@ -145,19 +149,30 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
eventTypeId = null,
} = props;
const query = trpc.viewer.calendars.connectedCalendars.useQuery(
{
eventTypeId: scope === SelectedCalendarSettingsScope.EventType ? eventTypeId! : null,
},
{
suspense: true,
refetchOnWindowFocus: false,
}
);
let queryInput: { eventTypeId: number } | undefined;
if (scope === SelectedCalendarSettingsScope.EventType) {
queryInput = { eventTypeId: eventTypeId! };
}
const { isPending } = props;
let initialData: RouterOutputs["viewer"]["calendars"]["connectedCalendars"] | undefined;
if (scope === SelectedCalendarSettingsScope.User && props.connectedCalendars) {
initialData = props.connectedCalendars;
}
const query = trpc.viewer.calendars.connectedCalendars.useQuery(queryInput, {
initialData,
suspense: true,
refetchOnWindowFocus: false,
});
const isPending = props.isPending;
const showScopeSelector = !!props.eventTypeId;
const isDisabled = disabledScope ? disabledScope === scope : false;
let isDisabled = false;
if (disabledScope) {
isDisabled = disabledScope === scope;
}
const shouldDisableConnectionModification = isDisabled || disableConnectionModification;
return (
<div>
@@ -170,7 +185,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
scope={scope}
shouldDisableConnectionModification={shouldDisableConnectionModification}
/>
{query.data?.connectedCalendars && query.data?.connectedCalendars.length > 0 ? (
{!!(query.data?.connectedCalendars && query.data?.connectedCalendars.length > 0) && (
<ConnectedCalendarList
fromOnboarding={props.fromOnboarding}
scope={scope}
@@ -180,7 +195,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
items={query.data.connectedCalendars}
isDisabled={isDisabled}
/>
) : null}
)}
</SelectedCalendarsSettings>
</div>
);
@@ -1,6 +1,3 @@
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
@@ -10,16 +7,14 @@ import {
getBusyTimesFromTeamLimits,
} from "@calcom/features/busyTimes/lib/getBusyTimesFromLimits";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { PrismaHolidayRepository } from "@calcom/features/holidays/repositories/PrismaHolidayRepository";
import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import type { WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule";
import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges";
import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-ranges";
import { getWorkingHours } from "@calcom/lib/availability";
import { stringToDayjsZod } from "@calcom/lib/dayjs";
import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getHolidayService } from "@calcom/lib/holidays";
import { getHolidayEmoji } from "@calcom/lib/holidays/getHolidayEmoji";
@@ -31,20 +26,23 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type {
Availability,
Booking,
User,
OutOfOfficeEntry,
OutOfOfficeReason,
EventType as PrismaEventType,
Availability,
SelectedCalendar,
TravelSchedule,
User,
} from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarFetchMode, EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
import type { TimeRange } from "@calcom/types/schedule";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { TimeRange, WorkingHours as WorkingHoursWithUserId } from "@calcom/types/schedule";
import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
import { detectEventTypeScheduleForUser } from "./detectEventTypeScheduleForUser";
const log = logger.getSubLogger({ prefix: ["getUserAvailability"] });
@@ -407,7 +405,7 @@ export class UserAvailabilityService {
const getBusyTimesEnd = dateTo.toISOString();
const selectedCalendars = eventType?.useEventLevelSelectedCalendars
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
? user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventType.id)
: user.userLevelSelectedCalendars;
let calendarTimezone: string | null = null;
@@ -4,7 +4,6 @@ import {
getConnectedCalendars,
} from "@calcom/features/calendars/lib/CalendarManager";
import { DestinationCalendarRepository } from "@calcom/features/calendars/repositories/DestinationCalendarRepository";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
import logger from "@calcom/lib/logger";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
@@ -44,7 +43,7 @@ const _ensureNoConflictingNonDelegatedConnectedCalendar = <
integration: { slug: string };
primary?: { email?: string | null | undefined } | undefined;
delegationCredentialId?: string | null | undefined;
}
},
>({
connectedCalendars,
loggedInUser,
@@ -216,7 +215,7 @@ function findMatchingCalendar({
calendar: DestinationCalendar;
}) {
// Check if destinationCalendar exists in connectedCalendars
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const allCals = connectedCalendars.flatMap((cal) => cal.calendars ?? []);
const matchingCalendar = allCals.find(
(cal) => cal.externalId === calendar.externalId && cal.integration === calendar.integration
);
@@ -255,14 +254,10 @@ function getSelectedCalendars({
}: {
user: UserWithCalendars;
eventTypeId: number | null;
}) {
}): Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId">[] {
if (eventTypeId) {
return EventTypeRepository.getSelectedCalendarsFromUser({
user,
eventTypeId: eventTypeId ?? null,
});
return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId);
}
return user.userLevelSelectedCalendars;
}
@@ -1408,16 +1408,6 @@ export class EventTypeRepository {
};
}
static getSelectedCalendarsFromUser<TSelectedCalendar extends { eventTypeId: number | null }>({
user,
eventTypeId,
}: {
user: UserWithSelectedCalendars<TSelectedCalendar>;
eventTypeId: number;
}) {
return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId);
}
async findByIdForUserAvailability({ id }: { id: number }) {
const eventType = await this.prismaClient.eventType.findUnique({
where: { id },
@@ -11,7 +11,21 @@ type ConnectedCalendarsOptions = {
input: TConnectedCalendarsInputSchema;
};
export const connectedCalendarsHandler = async ({ ctx, input }: ConnectedCalendarsOptions) => {
type GetConnectedDestinationCalendarsAndEnsureDefaultsInDbResult = Awaited<
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
>;
type ConnectedCalendarsHandlerResult = {
destinationCalendar: GetConnectedDestinationCalendarsAndEnsureDefaultsInDbResult["destinationCalendar"];
connectedCalendars: (GetConnectedDestinationCalendarsAndEnsureDefaultsInDbResult["connectedCalendars"][number] & {
cacheUpdatedAt: null;
})[];
};
export const connectedCalendarsHandler = async ({
ctx,
input,
}: ConnectedCalendarsOptions): Promise<ConnectedCalendarsHandlerResult> => {
const { user } = ctx;
const onboarding = input?.onboarding || false;
@@ -1,12 +1,19 @@
import { z } from "zod";
export const ZConnectedCalendarsInputSchema = z
export type TConnectedCalendarsInputSchema =
| {
onboarding?: boolean;
// Fetches the calendars for this event-type only if present
// Otherwise, fetches the calendars for the authenticated user
eventTypeId?: number | null;
}
| undefined;
export const ZConnectedCalendarsInputSchema: z.ZodType<TConnectedCalendarsInputSchema> = z
.object({
onboarding: z.boolean().optional(),
// Fetches the calendars for this event-type only if present
// Otherwise, fetches the calendars for the authenticated user
eventTypeId: z.number().nullable(),
eventTypeId: z.number().nullish(),
})
.optional();
export type TConnectedCalendarsInputSchema = z.infer<typeof ZConnectedCalendarsInputSchema>;