feat: add calendar cache status and actions (#22532)

* feat: add calendar cache status dropdown

- Add updatedAt field to CalendarCache schema with migration
- Create tRPC cacheStatus endpoint for fetching cache timestamps
- Add action dropdown to CalendarSwitch for Google Calendar entries
- Display formatted last updated timestamp in dropdown
- Add placeholder for cache deletion functionality
- Include translation strings for dropdown content

The dropdown only appears for Google Calendar integrations that have
active cache entries and provides cache management options for future
extensibility.

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: resolve Prisma type incompatibilities in repository files

- Remove problematic satisfies clause in selectedCalendar.ts
- Add missing cacheStatus parameter to ConnectedCalendarList component
- Fixes type errors that were preventing CI from passing

Co-Authored-By: zomars@cal.com <zomars@me.com>

* refactor: integrate cache status into connectedCalendars handler

- Remove separate cacheStatus tRPC endpoint as requested
- Return cache status as separate field in connectedCalendars response
- Update UI components to use cache data from connectedCalendars
- Fix Prisma type incompatibilities in repository files

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: resolve Prisma type incompatibilities and fix data flow for cache status

- Fix Prisma.SortOrder usage in membership.ts orderBy clauses
- Remove problematic satisfies clause in selectedCalendar.ts
- Fix TeamSelect type reference in team.ts
- Update SelectedCalendarsSettingsWebWrapper to properly pass cacheStatus data flow

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Discard changes to packages/lib/server/repository/membership.ts

* Discard changes to packages/lib/server/repository/team.ts

* fix: improve calendar cache dropdown with proper formatting and subscription logic

- Fix timestamp HTML entity encoding with interpolation escapeValue: false
- Only show dropdown for subscribed Google calendars (googleChannelId exists)
- Hide delete option when no cache data exists
- Include updatedAt and googleChannelId fields upstream in user repository
- Update data flow to pass subscription status through components

Co-Authored-By: zomars@cal.com <zomars@me.com>

* feat: update SelectedCalendar.updatedAt when Google webhooks trigger cache refresh

- Add updateManyByCredentialId method to SelectedCalendarRepository
- Update fetchAvailabilityAndSetCache to refresh SelectedCalendar timestamps
- Ensure webhook flow updates both CalendarCache and SelectedCalendar records
- Maintain proper timestamp tracking for calendar cache operations

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Add script to automate Tunnelmole webhook setup

Introduces test-gcal-webhooks.sh to start Tunnelmole, extract the public URL, and update GOOGLE_WEBHOOK_URL in the .env file. Handles process management, rate limits, and ensures environment configuration for Google Calendar webhooks.

* Update dev:cron script to use npx tsx

Replaces 'ts-node' with 'npx tsx' in the dev:cron script for running cron-tester.ts, likely to improve compatibility or leverage tsx features.

* Update cache status string and improve CalendarSwitch UI

Renamed 'last_updated' to 'cache_last_updated' in locale file for clarity and updated CalendarSwitch to use the new string. Also added dark mode text color support for cache status display.

* refactor: move cache management to credential-level dropdown with Remove App

- Create CredentialActionsDropdown component consolidating cache and app removal actions
- Add deleteCache tRPC mutation for credential-level cache deletion
- Update connectedCalendars handler to include cacheUpdatedAt at credential level
- Move dropdown from individual CalendarSwitch to credential level in SelectedCalendarsSettingsWebWrapper
- Remove cache-related props from CalendarSwitch component
- Add translation strings for cache management actions
- Consolidate all credential-level actions (cache management + Remove App) in one dropdown

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: remove duplicate translation keys in common.json

- Remove duplicate cache-related keys at lines 51-56
- Keep properly positioned keys later in file
- Addresses GitHub comment from zomars about duplicate keys

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: rename translation key to cache_last_updated

- Address GitHub comment from zomars
- Rename 'last_updated' to 'cache_last_updated' for specificity
- Update usage in CredentialActionsDropdown component

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: remove duplicate last_updated translation key

Co-Authored-By: zomars@cal.com <zomars@me.com>

* fix: add confirmation dialog for cache deletion and use repository pattern

- Add confirmation dialog for destructive cache deletion action
- Replace direct Prisma calls with CalendarCacheRepository pattern
- Add getCacheStatusByCredentialIds method to repository interface
- Fix import paths for UI components
- Address GitHub review comments from zomars

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Update CredentialActionsDropdown.tsx

* Update common.json

* Update common.json

* fix: remove nested div wrapper to resolve HTML structure error

- Remove wrapping div around DisconnectIntegration component
- Fixes nested <p> tag validation error preventing Remove App functionality
- Maintains existing confirmation dialog patterns

Co-Authored-By: zomars@cal.com <zomars@me.com>

* Fix API handler response termination logic

Removed unnecessary return values after setting status in the integrations API handler. This clarifies response handling and prevents returning the response object when not needed. Resolves "API handler should not return a value, received object".

* fix: 400 is correct error code for computing slot for past booking (#22574)

* fix

* add test

* chore: release v5.5.1

* Refactor credential disconnect to use confirmation dialog

Replaces the DisconnectIntegration component with an inline confirmation dialog for removing app credentials. Adds disconnect mutation logic and updates UI to improve user experience and consistency.

* Set default value for CalendarCache.updatedAt

Added a default value of NOW() for the updatedAt column in the CalendarCache table to ensure existing and future rows have a valid timestamp. Updated the Prisma schema to reflect this change and provide compatibility for legacy data and raw inserts.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: emrysal <me@alexvanandel.com>
This commit is contained in:
Omar López
2025-07-21 19:57:57 -07:00
committed by GitHub
parent 3784421f02
commit cb78692347
17 changed files with 379 additions and 31 deletions
+1 -1
View File
@@ -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",
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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(
@@ -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>
</>
);
}
@@ -27,4 +27,7 @@ export interface ICalendarCacheRepository {
userId: number | null;
args: FreeBusyArgs;
}): Promise<CalendarCache | null>;
getCacheStatusByCredentialIds(
credentialIds: number[]
): Promise<{ credentialId: number; updatedAt: Date | null }[]>;
}
@@ -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 [];
}
}
@@ -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,
}));
}
}
@@ -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;
};
@@ -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 }) {
@@ -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,
+2
View File
@@ -896,6 +896,8 @@ export class UserRepository {
eventTypeId: true,
externalId: true,
integration: true,
updatedAt: true,
googleChannelId: true,
},
},
completedOnboarding: true,
@@ -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";
@@ -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 && (
@@ -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>
@@ -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>
}
/>
);
@@ -162,6 +159,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
refetchOnWindowFocus: false,
}
);
const { isPending } = props;
const showScopeSelector = !!props.eventTypeId;
const isDisabled = disabledScope ? disabledScope === scope : false;
@@ -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
View File
@@ -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)
@@ -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 });
}),
});
@@ -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,
};
};
@@ -0,0 +1,33 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type DeleteCacheOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
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 };
};
+79
View File
@@ -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