Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "yarn copy-static && next dev --turbopack",
"dev:cron": "ts-node cron-tester.ts",
"dev:cron": "npx tsx cron-tester.ts",
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
"dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3379,5 +3379,12 @@
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
"event_type_no_hosts": "No hosts are assigned to event type",
"cache_status": "Cache Status",
"cache_last_updated": "Last updated: {{timestamp}}",
"delete_cached_data": "Delete cached data",
"cache_deleted_successfully": "Cache deleted successfully",
"error_deleting_cache": "Error deleting cache",
"confirm_delete_cache": "Are you sure you want to delete the cached data? This action cannot be undone.",
"yes_delete_cache": "Yes, delete cache",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
3 changes: 3 additions & 0 deletions packages/app-store/googlecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,9 @@ export default class GoogleCalendarService implements Calendar {
const data = await this.fetchAvailability(parsedArgs);
await this.setAvailabilityInCache(parsedArgs, data);
}

// Update SelectedCalendar.updatedAt for all calendars under this credential
await SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The call to bulk-update selected calendars passes an empty data object to updateManyByCredentialId, which (via Prisma's updateMany) can throw at runtime because no fields are provided to update and, even if it didn't throw, would never actually bump updatedAt, so the comment's intent ("Update SelectedCalendar.updatedAt") is not fulfilled. [logic error]

Severity Level: Critical 🚨
- ❌ Google Calendar webhook fails when refreshing cached availability.
- ❌ SelectedCalendar.updatedAt never reflects latest cache refresh time.
- ⚠️ Cache status UI shows stale timestamps for Google calendars.
Suggested change
await SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {});
await SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {
updatedAt: new Date(),
});
Steps of Reproduction ✅
1. Set up a Cal.com environment with a real database and enable Google Calendar cache so
that Google push notifications are handled (webhook URL is
`/api/integrations/googlecalendar/webhook` as defined via `GOOGLE_WEBHOOK_URL` in
`packages/app-store/googlecalendar/lib/CalendarService.ts:40-42`).

2. Ensure at least one `SelectedCalendar` row exists for a Google credential (this is done
in normal flows via cron/selection logic, e.g. `handleCreateSelectedCalendars` in
`apps/web/app/api/cron/selected-calendars/route.ts:247-305`, which creates
`SelectedCalendar` records using `SelectedCalendarRepository.create` in
`packages/lib/server/repository/selectedCalendar.ts:66-80`).

3. Trigger a Google Calendar push notification so the webhook handler at
`packages/app-store/googlecalendar/api/webhook.ts:46` (found via Grep) executes `await
calendarServiceForCalendarCache?.fetchAvailabilityAndSetCache?.(selectedCalendars);`,
calling `GoogleCalendarService.fetchAvailabilityAndSetCache` in
`packages/app-store/googlecalendar/lib/CalendarService.ts:990-1025`.

4. Inside `fetchAvailabilityAndSetCache`, after successfully fetching availability and
calling `setAvailabilityInCache`, the code at `CalendarService.ts:1023-1024` executes
`SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {});`, which
forwards to `prisma.selectedCalendar.updateMany({ where: { credentialId }, data: {} })` in
`packages/lib/server/repository/selectedCalendar.ts:400-404`, causing Prisma to throw a
runtime validation error because `data` is empty and also failing to update `updatedAt`,
so the webhook request returns a 500 and no `SelectedCalendar.updatedAt` values are
bumped.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/app-store/googlecalendar/lib/CalendarService.ts
**Line:** 1024:1024
**Comment:**
	*Logic Error: The call to bulk-update selected calendars passes an empty data object to `updateManyByCredentialId`, which (via Prisma's `updateMany`) can throw at runtime because no fields are provided to update and, even if it didn't throw, would never actually bump `updatedAt`, so the comment's intent ("Update SelectedCalendar.updatedAt") is not fulfilled.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

}

async createSelectedCalendar(
Expand Down
157 changes: 157 additions & 0 deletions packages/features/apps/components/CredentialActionsDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"use client";

import { useState } from "react";

import { useLocale } from "@calcom/lib/hooks/useLocale";
import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { Dialog } from "@calcom/ui/components/dialog";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";

interface CredentialActionsDropdownProps {
credentialId: number;
integrationType: string;
cacheUpdatedAt?: Date | null;
onSuccess?: () => void;
delegationCredentialId?: string | null;
disableConnectionModification?: boolean;
}

export default function CredentialActionsDropdown({
credentialId,
integrationType,
cacheUpdatedAt,
onSuccess,
delegationCredentialId,
disableConnectionModification,
}: CredentialActionsDropdownProps) {
const { t } = useLocale();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);

const deleteCacheMutation = trpc.viewer.calendars.deleteCache.useMutation({
onSuccess: () => {
showToast(t("cache_deleted_successfully"), "success");
onSuccess?.();
},
onError: () => {
showToast(t("error_deleting_cache"), "error");
},
});

const utils = trpc.useUtils();
const disconnectMutation = trpc.viewer.credentials.delete.useMutation({
onSuccess: () => {
showToast(t("app_removed_successfully"), "success");
onSuccess?.();
},
onError: () => {
showToast(t("error_removing_app"), "error");
},
async onSettled() {
await utils.viewer.calendars.connectedCalendars.invalidate();
await utils.viewer.apps.integrations.invalidate();
},
});

const isGoogleCalendar = integrationType === GOOGLE_CALENDAR_TYPE;
const canDisconnect = !delegationCredentialId && !disableConnectionModification;
const hasCache = isGoogleCalendar && cacheUpdatedAt;

if (!canDisconnect && !hasCache) {
return null;
}

return (
<>
<Dropdown open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="secondary" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{hasCache && (
<>
<DropdownMenuItem className="focus:ring-muted">
<div className="px-2 py-1">
<div className="text-sm font-medium text-gray-900 dark:text-white">{t("cache_status")}</div>
<div className="text-xs text-gray-500 dark:text-white">
{t("cache_last_updated", {
timestamp: new Intl.DateTimeFormat("en-US", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(cacheUpdatedAt)),
interpolation: { escapeValue: false },
})}
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
color="destructive"
StartIcon="trash"
onClick={() => {
setDeleteModalOpen(true);
setDropdownOpen(false);
}}>
{t("delete_cached_data")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{canDisconnect && hasCache && <hr className="my-1" />}
{canDisconnect && (
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
color="destructive"
StartIcon="trash"
onClick={() => {
setDisconnectModalOpen(true);
setDropdownOpen(false);
}}>
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>

<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_cached_data")}
confirmBtnText={t("yes_delete_cache")}
onConfirm={() => {
deleteCacheMutation.mutate({ credentialId });
setDeleteModalOpen(false);
}}>
{t("confirm_delete_cache")}
</ConfirmationDialogContent>
</Dialog>

<Dialog open={disconnectModalOpen} onOpenChange={setDisconnectModalOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_app")}
confirmBtnText={t("yes_remove_app")}
onConfirm={() => {
disconnectMutation.mutate({ id: credentialId });
setDisconnectModalOpen(false);
}}>
{t("are_you_sure_you_want_to_remove_this_app")}
</ConfirmationDialogContent>
</Dialog>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ export interface ICalendarCacheRepository {
userId: number | null;
args: FreeBusyArgs;
}): Promise<CalendarCache | null>;
getCacheStatusByCredentialIds(
credentialIds: number[]
): Promise<{ credentialId: number; updatedAt: Date | null }[]>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,9 @@ export class CalendarCacheRepositoryMock implements ICalendarCacheRepository {
async deleteManyByCredential() {
log.info(`Skipping deleteManyByCredential due to calendar-cache being disabled`);
}

async getCacheStatusByCredentialIds() {
log.info(`Skipping getCacheStatusByCredentialIds due to calendar-cache being disabled`);
return [];
}
}
17 changes: 17 additions & 0 deletions packages/features/calendar-cache/calendar-cache.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,21 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
},
});
}

async getCacheStatusByCredentialIds(credentialIds: number[]) {
const cacheStatuses = await prisma.calendarCache.groupBy({
by: ["credentialId"],
where: {
credentialId: { in: credentialIds },
},
_max: {
updatedAt: true,
},
});

return cacheStatuses.map((cache) => ({
credentialId: cache.credentialId,
updatedAt: cache._max.updatedAt,
}));
}
}
10 changes: 8 additions & 2 deletions packages/lib/getConnectedDestinationCalendars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ type ReturnTypeGetConnectedCalendars = Awaited<ReturnType<typeof getConnectedCal
type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"];

export type UserWithCalendars = Pick<User, "id" | "email"> & {
allSelectedCalendars: Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId">[];
userLevelSelectedCalendars: Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId">[];
allSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
>[];
userLevelSelectedCalendars: Pick<
SelectedCalendar,
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
>[];
destinationCalendar: DestinationCalendar | null;
};

Expand Down
10 changes: 8 additions & 2 deletions packages/lib/server/repository/selectedCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,7 @@ export class SelectedCalendarRepository {
}

static async findMany({ where, select, orderBy }: FindManyArgs) {
const args = { where, select, orderBy } satisfies Prisma.SelectedCalendarFindManyArgs;
return await prisma.selectedCalendar.findMany(args);
return await prisma.selectedCalendar.findMany({ where, select, orderBy });
}

static async findUniqueOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
Expand Down Expand Up @@ -398,6 +397,13 @@ export class SelectedCalendarRepository {
});
}

static async updateManyByCredentialId(credentialId: number, data: Prisma.SelectedCalendarUpdateInput) {
return await prisma.selectedCalendar.updateMany({
where: { credentialId },
data,
});
}

static async setErrorInWatching({ id, error }: { id: string; error: string }) {
await SelectedCalendarRepository.updateById(id, {
error,
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/server/repository/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,8 @@ export class UserRepository {
eventTypeId: true,
externalId: true,
integration: true,
updatedAt: true,
googleChannelId: true,
},
},
completedOnboarding: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Link from "next/link";
import React from "react";

import AppListCard from "@calcom/features/apps/components/AppListCard";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import CredentialActionsDropdown from "@calcom/features/apps/components/CredentialActionsDropdown";
import AdditionalCalendarSelector from "@calcom/features/calendars/AdditionalCalendarSelector";
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
Expand Down Expand Up @@ -67,18 +67,16 @@ const ConnectedCalendarList = ({
description={connectedCalendar.primary?.email ?? connectedCalendar.integration.description}
className="border-subtle mt-4 rounded-lg border"
actions={
// Delegation credential can't be disconnected
!connectedCalendar.delegationCredentialId &&
!disableConnectionModification && (
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
onSuccess={onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
)
<div className="flex w-32 justify-end">
<CredentialActionsDropdown
credentialId={connectedCalendar.credentialId}
integrationType={connectedCalendar.integration.type}
cacheUpdatedAt={connectedCalendar.cacheUpdatedAt}
onSuccess={onChanged}
delegationCredentialId={connectedCalendar.delegationCredentialId}
disableConnectionModification={disableConnectionModification}
/>
</div>
}>
<div className="border-subtle border-t">
{!fromOnboarding && (
Expand All @@ -97,7 +95,7 @@ const ConnectedCalendarList = ({
destination={cal.externalId === destinationCalendarId}
credentialId={cal.credentialId}
eventTypeId={shouldUseEventTypeScope ? eventTypeId : null}
delegationCredentialId={connectedCalendar.delegationCredentialId}
delegationCredentialId={connectedCalendar.delegationCredentialId || null}
/>
))}
</ul>
Expand All @@ -122,17 +120,16 @@ const ConnectedCalendarList = ({
}
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
actions={
// Delegation credential can't be disconnected
!connectedCalendar.delegationCredentialId && (
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
onSuccess={onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
)
<div className="flex w-32 justify-end">
<CredentialActionsDropdown
credentialId={connectedCalendar.credentialId}
integrationType={connectedCalendar.integration.type}
cacheUpdatedAt={connectedCalendar.cacheUpdatedAt}
onSuccess={onChanged}
delegationCredentialId={connectedCalendar.delegationCredentialId}
disableConnectionModification={disableConnectionModification}
/>
</div>
}
/>
);
Expand Down Expand Up @@ -162,6 +159,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
refetchOnWindowFocus: false,
}
);

const { isPending } = props;
const showScopeSelector = !!props.eventTypeId;
const isDisabled = disabledScope ? disabledScope === scope : false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
Warnings:

- Added the required column `updatedAt` to the `CalendarCache` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
-- Add the column with a default value to safely handle existing rows
ALTER TABLE "CalendarCache" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
2 changes: 2 additions & 0 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,8 @@ model CalendarCache {
key String
value Json
expiresAt DateTime
// Provide an initial value for legacy rows and future raw inserts
updatedAt DateTime @default(now()) @updatedAt
credentialId Int
userId Int?
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade)
Expand Down
9 changes: 9 additions & 0 deletions packages/trpc/server/routers/viewer/calendars/_router.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { z } from "zod";

import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
Expand All @@ -22,4 +24,11 @@ export const calendarsRouter = router({

return setDestinationCalendarHandler({ ctx, input });
}),

deleteCache: authedProcedure
.input(z.object({ credentialId: z.number() }))
.mutation(async ({ ctx, input }) => {
const { deleteCacheHandler } = await import("./deleteCache.handler");
return deleteCacheHandler({ ctx, input });
}),
});
Loading