* 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>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/lib/CalendarManager";
|
|
import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer";
|
|
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
|
|
import logger from "@calcom/lib/logger";
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import prisma from "@calcom/prisma";
|
|
import type { DestinationCalendar, SelectedCalendar, User } from "@calcom/prisma/client";
|
|
import { AppCategories } from "@calcom/prisma/enums";
|
|
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
|
|
|
import { DestinationCalendarRepository } from "./server/repository/destinationCalendar";
|
|
import { EventTypeRepository } from "./server/repository/eventType";
|
|
import { SelectedCalendarRepository } from "./server/repository/selectedCalendar";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["getConnectedDestinationCalendarsAndEnsureDefaultsInDb"] });
|
|
|
|
type ReturnTypeGetConnectedCalendars = Awaited<ReturnType<typeof getConnectedCalendars>>;
|
|
type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"];
|
|
|
|
export type UserWithCalendars = Pick<User, "id" | "email"> & {
|
|
allSelectedCalendars: Pick<
|
|
SelectedCalendar,
|
|
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
|
|
>[];
|
|
userLevelSelectedCalendars: Pick<
|
|
SelectedCalendar,
|
|
"externalId" | "integration" | "eventTypeId" | "updatedAt" | "googleChannelId"
|
|
>[];
|
|
destinationCalendar: DestinationCalendar | null;
|
|
};
|
|
|
|
export type ConnectedDestinationCalendars = Awaited<
|
|
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
|
|
>;
|
|
|
|
/**
|
|
* Ensures that when DelegationCredential is enabled and there is already a calendar connected for the corresponding domain, we only allow the DelegationCredential calendar to be returned
|
|
* This is to ensure that duplicate calendar connections aren't shown in UI(apps/installed/calendars). We choose DelegationCredential connection to be shown because we don't want users to be able to work with individual calendars
|
|
*/
|
|
const _ensureNoConflictingNonDelegatedConnectedCalendar = <
|
|
T extends {
|
|
integration: { slug: string };
|
|
primary?: { email?: string | null | undefined } | undefined;
|
|
delegationCredentialId?: string | null | undefined;
|
|
}
|
|
>({
|
|
connectedCalendars,
|
|
loggedInUser,
|
|
}: {
|
|
connectedCalendars: T[];
|
|
loggedInUser: { email: string };
|
|
}) => {
|
|
return connectedCalendars.filter((connectedCalendar, index, array) => {
|
|
const allCalendarsWithSameAppSlug = array.filter(
|
|
(cal) => cal.integration.slug === connectedCalendar.integration.slug
|
|
);
|
|
|
|
// If no other calendar with this slug, keep it
|
|
if (allCalendarsWithSameAppSlug.length === 1) return true;
|
|
|
|
const delegatedCalendarsWithSameAppSlug = allCalendarsWithSameAppSlug.filter(
|
|
(cal) => cal.delegationCredentialId
|
|
);
|
|
if (!delegatedCalendarsWithSameAppSlug.length) {
|
|
return true;
|
|
}
|
|
|
|
if (connectedCalendar.delegationCredentialId) {
|
|
return true;
|
|
}
|
|
|
|
// DelegationCredential Credential is always of the loggedInUser
|
|
if (!connectedCalendar.primary?.email || connectedCalendar.primary.email !== loggedInUser.email) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
};
|
|
|
|
async function handleNoConnectedCalendars(user: UserWithCalendars) {
|
|
log.debug(`No connected calendars, deleting destination calendar if it exists for user ${user.id}`);
|
|
|
|
if (!user.destinationCalendar) return user;
|
|
await prisma.destinationCalendar.delete({
|
|
where: { userId: user.id },
|
|
});
|
|
|
|
return {
|
|
...user,
|
|
destinationCalendar: null,
|
|
};
|
|
}
|
|
|
|
type ToggledCalendarDetails = {
|
|
externalId: string;
|
|
integration: string;
|
|
};
|
|
|
|
async function handleNoDestinationCalendar({
|
|
user,
|
|
connectedCalendars,
|
|
onboarding,
|
|
}: {
|
|
user: UserWithCalendars;
|
|
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
|
|
onboarding: boolean;
|
|
}) {
|
|
if (!connectedCalendars.length) {
|
|
throw new Error("No connected calendars");
|
|
}
|
|
|
|
// This is the calendar that we will ensure is enabled for conflict check
|
|
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
|
|
|
|
log.debug(
|
|
`There are connected calendars, but no destination calendar, so create a default destination calendar in DB for user ${user.id}`
|
|
);
|
|
/*
|
|
So create a default destination calendar with the first primary connected calendar
|
|
*/
|
|
const {
|
|
integration = "",
|
|
externalId = "",
|
|
credentialId,
|
|
delegationCredentialId,
|
|
email: primaryEmail,
|
|
} = connectedCalendars[0].primary ?? {};
|
|
|
|
// Select the first calendar matching the primary by default since that will also be the destination calendar
|
|
if (onboarding && externalId) {
|
|
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
|
|
(item) => item.externalId === externalId && item.integration === integration
|
|
);
|
|
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
|
|
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
|
|
calendarToEnsureIsEnabledForConflictCheck = {
|
|
externalId,
|
|
integration,
|
|
};
|
|
}
|
|
}
|
|
|
|
user.destinationCalendar = await DestinationCalendarRepository.createIfNotExistsForUser({
|
|
userId: user.id,
|
|
integration,
|
|
externalId,
|
|
primaryEmail,
|
|
...(!isDelegationCredential({ credentialId })
|
|
? {
|
|
credentialId,
|
|
}
|
|
: {
|
|
delegationCredentialId,
|
|
}),
|
|
});
|
|
|
|
return {
|
|
user,
|
|
connectedCalendars,
|
|
calendarToEnsureIsEnabledForConflictCheck,
|
|
};
|
|
}
|
|
|
|
async function handleDestinationCalendarNotInConnectedCalendars({
|
|
user,
|
|
connectedCalendars,
|
|
onboarding,
|
|
}: {
|
|
user: UserWithCalendars;
|
|
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
|
|
onboarding: boolean;
|
|
}) {
|
|
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
|
|
log.debug(
|
|
`Destination calendar isn't in connectedCalendars, update it to the first primary connected calendar for user ${user.id}`
|
|
);
|
|
const { integration = "", externalId = "", email: primaryEmail } = connectedCalendars[0].primary ?? {};
|
|
// Select the first calendar matching the primary by default since that will also be the destination calendar
|
|
if (onboarding && externalId) {
|
|
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
|
|
(item) => item.externalId === externalId && item.integration === integration
|
|
);
|
|
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
|
|
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
|
|
calendarToEnsureIsEnabledForConflictCheck = {
|
|
externalId,
|
|
integration,
|
|
};
|
|
}
|
|
}
|
|
|
|
user.destinationCalendar = await prisma.destinationCalendar.update({
|
|
where: { userId: user.id },
|
|
data: {
|
|
integration,
|
|
externalId,
|
|
primaryEmail,
|
|
},
|
|
});
|
|
|
|
return {
|
|
user,
|
|
connectedCalendars,
|
|
calendarToEnsureIsEnabledForConflictCheck,
|
|
};
|
|
}
|
|
|
|
function findMatchingCalendar({
|
|
connectedCalendars,
|
|
calendar,
|
|
}: {
|
|
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
|
|
calendar: DestinationCalendar;
|
|
}) {
|
|
// Check if destinationCalendar exists in connectedCalendars
|
|
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
|
|
const matchingCalendar = allCals.find(
|
|
(cal) => cal.externalId === calendar.externalId && cal.integration === calendar.integration
|
|
);
|
|
return matchingCalendar;
|
|
}
|
|
|
|
async function ensureSelectedCalendarIsInDb({
|
|
user,
|
|
selectedCalendar,
|
|
eventTypeId,
|
|
}: {
|
|
user: UserWithCalendars;
|
|
selectedCalendar: {
|
|
integration: string;
|
|
externalId: string;
|
|
};
|
|
eventTypeId: number | null;
|
|
}) {
|
|
console.log(
|
|
`Upsert the selectedCalendar record to the DB for user ${user.id} with details ${JSON.stringify(
|
|
selectedCalendar
|
|
)}`
|
|
);
|
|
|
|
await SelectedCalendarRepository.createIfNotExists({
|
|
userId: user.id,
|
|
integration: selectedCalendar.integration,
|
|
externalId: selectedCalendar.externalId,
|
|
eventTypeId,
|
|
});
|
|
}
|
|
|
|
function getSelectedCalendars({
|
|
user,
|
|
eventTypeId,
|
|
}: {
|
|
user: UserWithCalendars;
|
|
eventTypeId: number | null;
|
|
}) {
|
|
if (eventTypeId) {
|
|
return EventTypeRepository.getSelectedCalendarsFromUser({
|
|
user,
|
|
eventTypeId: eventTypeId ?? null,
|
|
});
|
|
}
|
|
|
|
return user.userLevelSelectedCalendars;
|
|
}
|
|
|
|
/**
|
|
* Fetches the calendars for the authenticated user or the event-type if provided
|
|
* It also takes care of updating the destination calendar in some edge cases
|
|
*/
|
|
export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
|
|
user,
|
|
onboarding,
|
|
eventTypeId,
|
|
prisma,
|
|
}: {
|
|
user: UserWithCalendars;
|
|
onboarding: boolean;
|
|
eventTypeId?: number | null;
|
|
prisma: PrismaClient;
|
|
}) {
|
|
const userCredentials = await prisma.credential.findMany({
|
|
where: {
|
|
userId: user.id,
|
|
app: {
|
|
categories: { has: AppCategories.calendar },
|
|
enabled: true,
|
|
},
|
|
},
|
|
select: credentialForCalendarServiceSelect,
|
|
});
|
|
|
|
const { credentials: allCredentials } = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
|
|
user: { id: user.id, email: user.email, credentials: userCredentials },
|
|
});
|
|
|
|
const selectedCalendars = getSelectedCalendars({ user, eventTypeId: eventTypeId ?? null });
|
|
// get user's credentials + their connected integrations
|
|
const calendarCredentials = getCalendarCredentials(allCredentials);
|
|
|
|
// get all the connected integrations' calendars (from third party)
|
|
const getConnectedCalendarsResult = await getConnectedCalendars(
|
|
calendarCredentials,
|
|
selectedCalendars,
|
|
user.destinationCalendar?.externalId
|
|
);
|
|
|
|
let connectedCalendars = getConnectedCalendarsResult.connectedCalendars;
|
|
const destinationCalendar = getConnectedCalendarsResult.destinationCalendar;
|
|
|
|
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
|
|
|
|
if (connectedCalendars.length === 0) {
|
|
user = await handleNoConnectedCalendars(user);
|
|
} else if (!user.destinationCalendar) {
|
|
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
|
|
await handleNoDestinationCalendar({
|
|
user,
|
|
connectedCalendars,
|
|
onboarding,
|
|
}));
|
|
} else {
|
|
/* There are connected calendars and a destination calendar */
|
|
log.debug(
|
|
`There are connected calendars and a destination calendar, so check if destinationCalendar exists in connectedCalendars for user ${user.id}`
|
|
);
|
|
|
|
const destinationCal = findMatchingCalendar({ connectedCalendars, calendar: user.destinationCalendar });
|
|
if (!destinationCal) {
|
|
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
|
|
await handleDestinationCalendarNotInConnectedCalendars({
|
|
user,
|
|
connectedCalendars,
|
|
onboarding,
|
|
}));
|
|
} else if (onboarding && !destinationCal.isSelected) {
|
|
log.debug(
|
|
`Onboarding:Destination calendar is not selected, but in connectedCalendars, so mark it as selected in the calendar list for user ${user.id}`
|
|
);
|
|
// Mark the destination calendar as selected in the calendar list
|
|
// We use every so that we can exit early once we find the matching calendar
|
|
connectedCalendars.every((cal) => {
|
|
const index = (cal.calendars || []).findIndex(
|
|
(calendar) =>
|
|
calendar.externalId === destinationCal.externalId &&
|
|
calendar.integration === destinationCal.integration
|
|
);
|
|
if (index >= 0 && cal.calendars) {
|
|
cal.calendars[index].isSelected = true;
|
|
calendarToEnsureIsEnabledForConflictCheck = {
|
|
externalId: destinationCal.externalId,
|
|
integration: destinationCal.integration || "",
|
|
};
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Insert the newly toggled record to the DB
|
|
if (calendarToEnsureIsEnabledForConflictCheck) {
|
|
await ensureSelectedCalendarIsInDb({
|
|
user,
|
|
selectedCalendar: calendarToEnsureIsEnabledForConflictCheck,
|
|
eventTypeId: eventTypeId ?? null,
|
|
});
|
|
}
|
|
|
|
const noConflictingNonDelegatedConnectedCalendars = _ensureNoConflictingNonDelegatedConnectedCalendar({
|
|
connectedCalendars,
|
|
loggedInUser: { email: user.email },
|
|
});
|
|
return {
|
|
connectedCalendars: noConflictingNonDelegatedConnectedCalendars,
|
|
destinationCalendar: {
|
|
...(user.destinationCalendar as DestinationCalendar),
|
|
...destinationCalendar,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Legacy export for @calcom/platform-libraries
|
|
export const getConnectedDestinationCalendars = getConnectedDestinationCalendarsAndEnsureDefaultsInDb;
|