Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a46f210
feat: add calendar cache status dropdown
devin-ai-integration[bot] Jul 15, 2025
7ea871c
fix: resolve Prisma type incompatibilities in repository files
devin-ai-integration[bot] Jul 15, 2025
57d4a0b
refactor: integrate cache status into connectedCalendars handler
devin-ai-integration[bot] Jul 15, 2025
1835d29
fix: resolve Prisma type incompatibilities and fix data flow for cach…
devin-ai-integration[bot] Jul 15, 2025
6293651
Discard changes to packages/lib/server/repository/membership.ts
zomars Jul 15, 2025
96ac303
Discard changes to packages/lib/server/repository/team.ts
zomars Jul 15, 2025
14dfef8
fix: improve calendar cache dropdown with proper formatting and subsc…
devin-ai-integration[bot] Jul 15, 2025
08762ca
feat: update SelectedCalendar.updatedAt when Google webhooks trigger …
devin-ai-integration[bot] Jul 15, 2025
b634834
Add script to automate Tunnelmole webhook setup
zomars Jul 15, 2025
a8747ea
Update dev:cron script to use npx tsx
zomars Jul 15, 2025
d6771e6
Update cache status string and improve CalendarSwitch UI
zomars Jul 16, 2025
4fb15e0
refactor: move cache management to credential-level dropdown with Rem…
devin-ai-integration[bot] Jul 16, 2025
da37a9c
fix: remove duplicate translation keys in common.json
devin-ai-integration[bot] Jul 16, 2025
7840511
fix: rename translation key to cache_last_updated
devin-ai-integration[bot] Jul 16, 2025
591e197
fix: remove duplicate last_updated translation key
devin-ai-integration[bot] Jul 16, 2025
c615a40
fix: add confirmation dialog for cache deletion and use repository pa…
devin-ai-integration[bot] Jul 16, 2025
20cdd46
Update CredentialActionsDropdown.tsx
zomars Jul 16, 2025
390316b
Update common.json
zomars Jul 16, 2025
1c07963
Merge branch 'main' into devin/calendar-cache-tooltip-1752595047
zomars Jul 16, 2025
f8266a1
Update common.json
zomars Jul 16, 2025
8c13fb0
fix: remove nested div wrapper to resolve HTML structure error
devin-ai-integration[bot] Jul 16, 2025
f0c6730
Fix API handler response termination logic
zomars Jul 16, 2025
d37aa79
fix: 400 is correct error code for computing slot for past booking (#…
hbjORbj Jul 16, 2025
ba785dc
chore: release v5.5.1
emrysal Jul 16, 2025
4076986
Refactor credential disconnect to use confirmation dialog
zomars Jul 17, 2025
77bbc37
Merge branch 'main' into devin/calendar-cache-tooltip-1752595047
zomars Jul 17, 2025
e1df0a5
Merge branch 'main' into devin/calendar-cache-tooltip-1752595047
zomars Jul 18, 2025
5fd11f9
Set default value for CalendarCache.updatedAt
zomars Jul 18, 2025
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 @@ -3378,5 +3378,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, {});
}

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