{!fromOnboarding && (
@@ -97,7 +95,7 @@ const ConnectedCalendarList = ({
destination={cal.externalId === destinationCalendarId}
credentialId={cal.credentialId}
eventTypeId={shouldUseEventTypeScope ? eventTypeId : null}
- delegationCredentialId={connectedCalendar.delegationCredentialId}
+ delegationCredentialId={connectedCalendar.delegationCredentialId || null}
/>
))}
@@ -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 && (
-
-
-
- )
+
+
+
}
/>
);
@@ -162,6 +159,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
refetchOnWindowFocus: false,
}
);
+
const { isPending } = props;
const showScopeSelector = !!props.eventTypeId;
const isDisabled = disabledScope ? disabledScope === scope : false;
diff --git a/packages/prisma/migrations/20250715160635_add_calendar_cache_updated_at/migration.sql b/packages/prisma/migrations/20250715160635_add_calendar_cache_updated_at/migration.sql
new file mode 100644
index 0000000000..53f16f5b88
--- /dev/null
+++ b/packages/prisma/migrations/20250715160635_add_calendar_cache_updated_at/migration.sql
@@ -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();
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index b4c1698dff..9da776099e 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -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)
diff --git a/packages/trpc/server/routers/viewer/calendars/_router.tsx b/packages/trpc/server/routers/viewer/calendars/_router.tsx
index 649c97649a..8eb06da36f 100644
--- a/packages/trpc/server/routers/viewer/calendars/_router.tsx
+++ b/packages/trpc/server/routers/viewer/calendars/_router.tsx
@@ -1,3 +1,5 @@
+import { z } from "zod";
+
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
@@ -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 });
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts b/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts
index 4553931985..3e4b22757d 100644
--- a/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts
+++ b/packages/trpc/server/routers/viewer/calendars/connectedCalendars.handler.ts
@@ -1,3 +1,4 @@
+import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
@@ -23,8 +24,19 @@ export const connectedCalendarsHandler = async ({ ctx, input }: ConnectedCalenda
prisma,
});
+ const credentialIds = connectedCalendars.map((cal) => cal.credentialId);
+ const cacheRepository = new CalendarCacheRepository();
+ const cacheStatuses = await cacheRepository.getCacheStatusByCredentialIds(credentialIds);
+
+ const cacheStatusMap = new Map(cacheStatuses.map((cache) => [cache.credentialId, cache.updatedAt]));
+
+ const enrichedConnectedCalendars = connectedCalendars.map((calendar) => ({
+ ...calendar,
+ cacheUpdatedAt: cacheStatusMap.get(calendar.credentialId) || null,
+ }));
+
return {
- connectedCalendars,
+ connectedCalendars: enrichedConnectedCalendars,
destinationCalendar,
};
};
diff --git a/packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts b/packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts
new file mode 100644
index 0000000000..06cc1b7b24
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts
@@ -0,0 +1,33 @@
+import { prisma } from "@calcom/prisma";
+import type { TrpcSessionUser } from "@calcom/trpc/server/types";
+
+type DeleteCacheOptions = {
+ ctx: {
+ user: NonNullable
;
+ };
+ input: {
+ credentialId: number;
+ };
+};
+
+export const deleteCacheHandler = async ({ ctx, input }: DeleteCacheOptions) => {
+ const { user } = ctx;
+ const { credentialId } = input;
+
+ const credential = await prisma.credential.findFirst({
+ where: {
+ id: credentialId,
+ userId: user.id,
+ },
+ });
+
+ if (!credential) {
+ throw new Error("Credential not found or access denied");
+ }
+
+ await prisma.calendarCache.deleteMany({
+ where: { credentialId },
+ });
+
+ return { success: true };
+};
diff --git a/scripts/test-gcal-webhooks.sh b/scripts/test-gcal-webhooks.sh
new file mode 100755
index 0000000000..35adab779d
--- /dev/null
+++ b/scripts/test-gcal-webhooks.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+
+# Configuration
+LOG_FILE="/tmp/tmole.log"
+ENV_FILE="../.env"
+TM_PORT=3000
+TM_KEYWORD="https://.*\.tunnelmole\.net"
+TMOLE_RUNNING=$(pgrep -f "tmole $TM_PORT")
+
+# Clean exit function
+cleanup() {
+ if [ "$OWNED_PID" = true ]; then
+ echo -e "\nStopping Tunnelmole (PID: $TMOLE_PID)..."
+ kill "$TMOLE_PID" 2>/dev/null
+ wait "$TMOLE_PID" 2>/dev/null
+ fi
+ exit 0
+}
+
+trap cleanup SIGINT SIGTERM
+
+# Function to extract URL from log
+extract_url_from_log() {
+ grep -oE "$TM_KEYWORD" "$LOG_FILE" | head -n 1
+}
+
+# If tmole is already running
+if [ -n "$TMOLE_RUNNING" ]; then
+ echo "Tunnelmole already running (PID: $TMOLE_RUNNING), reusing..."
+ TUNNEL_URL=$(extract_url_from_log)
+ OWNED_PID=false
+else
+ echo "Starting a new Tunnelmole session..."
+ rm -f "$LOG_FILE"
+ tmole $TM_PORT > "$LOG_FILE" 2>&1 &
+ TMOLE_PID=$!
+ OWNED_PID=true
+
+ # Wait for URL or error
+ echo "Waiting for tmole to initialize..."
+ for i in {1..20}; do
+ if grep -q "$TM_KEYWORD" "$LOG_FILE"; then
+ TUNNEL_URL=$(extract_url_from_log)
+ break
+ fi
+ if grep -q "limited to 10 tunnels per hour" "$LOG_FILE"; then
+ echo "❌ Rate limit hit: You've used your 10 free tunnels/hour. Sign up at:"
+ echo "👉 https://dashboard.tunnelmole.com/upgrade/run-more-tunnels-faster"
+ cleanup
+ fi
+ sleep 0.5
+ done
+fi
+
+# Check that we actually got a URL
+if [ -z "$TUNNEL_URL" ]; then
+ echo "❌ Failed to extract Tunnelmole URL."
+ cleanup
+fi
+
+# Update env file
+if [ ! -f "$ENV_FILE" ]; then
+ echo "⚠️ $ENV_FILE not found. Creating new file."
+ touch "$ENV_FILE"
+fi
+
+if grep -q '^GOOGLE_WEBHOOK_URL=' "$ENV_FILE"; then
+ sed -i '' -E "s|^GOOGLE_WEBHOOK_URL=.*|GOOGLE_WEBHOOK_URL=$TUNNEL_URL|" "$ENV_FILE"
+else
+ echo "GOOGLE_WEBHOOK_URL=$TUNNEL_URL" >> "$ENV_FILE"
+fi
+
+echo "✅ Updated GOOGLE_WEBHOOK_URL in $ENV_FILE to $TUNNEL_URL"
+echo "Tunnelmole is running. Press Ctrl+C to stop."
+
+# Keep script alive only if we started tmole
+if [ "$OWNED_PID" = true ]; then
+ wait "$TMOLE_PID"
+fi